# TASK 4.4: Add MonitorRun History Recording

## Summary

Create the repository interface and implementation for managing `MonitorRun` entities, enabling tracking and querying of monitor execution history.

## Dependencies

- TASK 1.2 (MonitorRun entity)
- TASK 1.3 (Migration applied)

## Files to Create

### 1. `backend/Kds.Ai.AnomalyDetector/Repositories/IMonitorRunRepository.cs`

```csharp
using Kds.Ai.Data.Entities;
using Kds.Ai.Data.Enums;

namespace Kds.Ai.AnomalyDetector.Repositories;

/// <summary>
/// Repository for managing MonitorRun execution history
/// </summary>
public interface IMonitorRunRepository
{
    /// <summary>
    /// Get a run by ID
    /// </summary>
    Task<MonitorRun?> GetByIdAsync(Guid id, CancellationToken ct = default);
    
    /// <summary>
    /// Get a run by ID with related MonitorSpec loaded
    /// </summary>
    Task<MonitorRun?> GetByIdWithSpecAsync(Guid id, CancellationToken ct = default);
    
    /// <summary>
    /// Get all runs for a monitor spec
    /// </summary>
    Task<IReadOnlyList<MonitorRun>> GetByMonitorSpecAsync(
        Guid monitorSpecId, 
        int limit = 100,
        CancellationToken ct = default);
    
    /// <summary>
    /// Get the most recent run for a monitor spec
    /// </summary>
    Task<MonitorRun?> GetLatestByMonitorSpecAsync(
        Guid monitorSpecId, 
        CancellationToken ct = default);
    
    /// <summary>
    /// Get runs within a time range
    /// </summary>
    Task<IReadOnlyList<MonitorRun>> GetByTimeRangeAsync(
        DateTimeOffset from,
        DateTimeOffset to,
        CancellationToken ct = default);
    
    /// <summary>
    /// Get runs by status
    /// </summary>
    Task<IReadOnlyList<MonitorRun>> GetByStatusAsync(
        MonitorRunStatus status,
        int limit = 100,
        CancellationToken ct = default);
    
    /// <summary>
    /// Get runs that detected anomalies
    /// </summary>
    Task<IReadOnlyList<MonitorRun>> GetWithAnomaliesAsync(
        int limit = 100,
        CancellationToken ct = default);
    
    /// <summary>
    /// Create a new run record
    /// </summary>
    Task<MonitorRun> CreateAsync(MonitorRun run, CancellationToken ct = default);
    
    /// <summary>
    /// Update a run record
    /// </summary>
    Task<MonitorRun> UpdateAsync(MonitorRun run, CancellationToken ct = default);
    
    /// <summary>
    /// Mark a run as completed with results
    /// </summary>
    Task MarkCompletedAsync(
        Guid id, 
        int anomaliesDetected,
        string? resultSummary,
        int? tokensUsed,
        CancellationToken ct = default);
    
    /// <summary>
    /// Mark a run as failed
    /// </summary>
    Task MarkFailedAsync(
        Guid id, 
        string errorMessage,
        CancellationToken ct = default);
    
    /// <summary>
    /// Get statistics for runs
    /// </summary>
    Task<RunStatistics> GetStatisticsAsync(
        DateTimeOffset? from = null,
        DateTimeOffset? to = null,
        CancellationToken ct = default);
    
    /// <summary>
    /// Delete old run records for cleanup
    /// </summary>
    /// <param name="olderThan">Delete runs older than this date</param>
    /// <param name="keepMinimum">Keep at least this many runs per spec</param>
    /// <param name="ct">Cancellation token</param>
    /// <returns>Number of runs deleted</returns>
    Task<int> CleanupOldRunsAsync(
        DateTimeOffset olderThan,
        int keepMinimum = 10,
        CancellationToken ct = default);
}

/// <summary>
/// Statistics about monitor runs
/// </summary>
public sealed record RunStatistics
{
    public required int TotalRuns { get; init; }
    public required int CompletedRuns { get; init; }
    public required int FailedRuns { get; init; }
    public required int RunsWithAnomalies { get; init; }
    public required int TotalAnomaliesDetected { get; init; }
    public required double AverageRunDurationMs { get; init; }
    public required int TotalTokensUsed { get; init; }
}
```

### 2. `backend/Kds.Ai.AnomalyDetector/Repositories/EfMonitorRunRepository.cs`

```csharp
using Kds.Ai.Data;
using Kds.Ai.Data.Entities;
using Kds.Ai.Data.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Kds.Ai.AnomalyDetector.Repositories;

internal sealed class EfMonitorRunRepository(
    KdsAiDbContext dbContext,
    ILogger<EfMonitorRunRepository> logger) : IMonitorRunRepository
{
    public async Task<MonitorRun?> GetByIdAsync(Guid id, CancellationToken ct = default)
    {
        return await dbContext.MonitorRuns
            .FirstOrDefaultAsync(x => x.Id == id, ct);
    }
    
    public async Task<MonitorRun?> GetByIdWithSpecAsync(Guid id, CancellationToken ct = default)
    {
        return await dbContext.MonitorRuns
            .Include(x => x.MonitorSpec)
            .FirstOrDefaultAsync(x => x.Id == id, ct);
    }
    
    public async Task<IReadOnlyList<MonitorRun>> GetByMonitorSpecAsync(
        Guid monitorSpecId, 
        int limit = 100,
        CancellationToken ct = default)
    {
        return await dbContext.MonitorRuns
            .Where(x => x.MonitorSpecId == monitorSpecId)
            .OrderByDescending(x => x.StartedAt)
            .Take(limit)
            .ToListAsync(ct);
    }
    
    public async Task<MonitorRun?> GetLatestByMonitorSpecAsync(
        Guid monitorSpecId, 
        CancellationToken ct = default)
    {
        return await dbContext.MonitorRuns
            .Where(x => x.MonitorSpecId == monitorSpecId)
            .OrderByDescending(x => x.StartedAt)
            .FirstOrDefaultAsync(ct);
    }
    
    public async Task<IReadOnlyList<MonitorRun>> GetByTimeRangeAsync(
        DateTimeOffset from,
        DateTimeOffset to,
        CancellationToken ct = default)
    {
        return await dbContext.MonitorRuns
            .Where(x => x.StartedAt >= from && x.StartedAt < to)
            .OrderByDescending(x => x.StartedAt)
            .ToListAsync(ct);
    }
    
    public async Task<IReadOnlyList<MonitorRun>> GetByStatusAsync(
        MonitorRunStatus status,
        int limit = 100,
        CancellationToken ct = default)
    {
        return await dbContext.MonitorRuns
            .Where(x => x.Status == status)
            .OrderByDescending(x => x.StartedAt)
            .Take(limit)
            .ToListAsync(ct);
    }
    
    public async Task<IReadOnlyList<MonitorRun>> GetWithAnomaliesAsync(
        int limit = 100,
        CancellationToken ct = default)
    {
        return await dbContext.MonitorRuns
            .Where(x => x.AnomaliesDetected > 0)
            .OrderByDescending(x => x.StartedAt)
            .Take(limit)
            .ToListAsync(ct);
    }
    
    public async Task<MonitorRun> CreateAsync(MonitorRun run, CancellationToken ct = default)
    {
        if (run.Id == Guid.Empty)
        {
            run.Id = Guid.NewGuid();
        }
        
        dbContext.MonitorRuns.Add(run);
        await dbContext.SaveChangesAsync(ct);
        
        logger.LogDebug("Created MonitorRun {Id} for spec {SpecId}", run.Id, run.MonitorSpecId);
        
        return run;
    }
    
    public async Task<MonitorRun> UpdateAsync(MonitorRun run, CancellationToken ct = default)
    {
        dbContext.MonitorRuns.Update(run);
        await dbContext.SaveChangesAsync(ct);
        
        return run;
    }
    
    public async Task MarkCompletedAsync(
        Guid id, 
        int anomaliesDetected,
        string? resultSummary,
        int? tokensUsed,
        CancellationToken ct = default)
    {
        var now = DateTimeOffset.UtcNow;
        
        await dbContext.MonitorRuns
            .Where(x => x.Id == id)
            .ExecuteUpdateAsync(setters => setters
                .SetProperty(x => x.Status, MonitorRunStatus.Completed)
                .SetProperty(x => x.CompletedAt, now)
                .SetProperty(x => x.AnomaliesDetected, anomaliesDetected)
                .SetProperty(x => x.ResultSummary, resultSummary)
                .SetProperty(x => x.TokensUsed, tokensUsed), ct);
    }
    
    public async Task MarkFailedAsync(
        Guid id, 
        string errorMessage,
        CancellationToken ct = default)
    {
        await dbContext.MonitorRuns
            .Where(x => x.Id == id)
            .ExecuteUpdateAsync(setters => setters
                .SetProperty(x => x.Status, MonitorRunStatus.Failed)
                .SetProperty(x => x.CompletedAt, DateTimeOffset.UtcNow)
                .SetProperty(x => x.ErrorMessage, errorMessage), ct);
    }
    
    public async Task<RunStatistics> GetStatisticsAsync(
        DateTimeOffset? from = null,
        DateTimeOffset? to = null,
        CancellationToken ct = default)
    {
        var query = dbContext.MonitorRuns.AsQueryable();
        
        if (from.HasValue)
            query = query.Where(x => x.StartedAt >= from.Value);
        
        if (to.HasValue)
            query = query.Where(x => x.StartedAt < to.Value);
        
        var stats = await query
            .GroupBy(x => 1)
            .Select(g => new
            {
                TotalRuns = g.Count(),
                CompletedRuns = g.Count(x => x.Status == MonitorRunStatus.Completed),
                FailedRuns = g.Count(x => x.Status == MonitorRunStatus.Failed),
                RunsWithAnomalies = g.Count(x => x.AnomaliesDetected > 0),
                TotalAnomalies = g.Sum(x => x.AnomaliesDetected),
                AvgDuration = g.Average(x => (double?)x.DurationMs) ?? 0,
                TotalTokens = g.Sum(x => x.TokensUsed ?? 0)
            })
            .FirstOrDefaultAsync(ct);
        
        return new RunStatistics
        {
            TotalRuns = stats?.TotalRuns ?? 0,
            CompletedRuns = stats?.CompletedRuns ?? 0,
            FailedRuns = stats?.FailedRuns ?? 0,
            RunsWithAnomalies = stats?.RunsWithAnomalies ?? 0,
            TotalAnomaliesDetected = stats?.TotalAnomalies ?? 0,
            AverageRunDurationMs = stats?.AvgDuration ?? 0,
            TotalTokensUsed = stats?.TotalTokens ?? 0
        };
    }
    
    public async Task<int> CleanupOldRunsAsync(
        DateTimeOffset olderThan,
        int keepMinimum = 10,
        CancellationToken ct = default)
    {
        // Get specs with more than minimum runs
        var specsToClean = await dbContext.MonitorRuns
            .GroupBy(x => x.MonitorSpecId)
            .Where(g => g.Count() > keepMinimum)
            .Select(g => g.Key)
            .ToListAsync(ct);
        
        var totalDeleted = 0;
        
        foreach (var specId in specsToClean)
        {
            // Get IDs to keep (most recent)
            var keepIds = await dbContext.MonitorRuns
                .Where(x => x.MonitorSpecId == specId)
                .OrderByDescending(x => x.StartedAt)
                .Take(keepMinimum)
                .Select(x => x.Id)
                .ToListAsync(ct);
            
            // Delete old runs
            var deleted = await dbContext.MonitorRuns
                .Where(x => x.MonitorSpecId == specId)
                .Where(x => x.StartedAt < olderThan)
                .Where(x => !keepIds.Contains(x.Id))
                .ExecuteDeleteAsync(ct);
            
            totalDeleted += deleted;
        }
        
        logger.LogInformation("Cleaned up {Count} old monitor runs", totalDeleted);
        
        return totalDeleted;
    }
}
```

## Files to Modify

### 3. `backend/Kds.Ai.AnomalyDetector/DependencyInjectionExtensions.cs`

Add repository registration:

```csharp
// Register run repository
builder.Services.AddScoped<IMonitorRunRepository, EfMonitorRunRepository>();
```

## Changes from Old Design

- Removed `GetByStoreAsync(storeGuid)` - MonitorSpec no longer has `StoreGuid`
- `MarkCompletedAsync` no longer takes `Guid? alertEventId` parameter - alerts are tracked via `MonitorRun.AlertEvents` collection (many-to-many join table), not a single FK
- Class is `internal sealed` with primary constructor
- `RunStatistics` is a `sealed record`

## Query Patterns

| Use Case | Method |
|----------|--------|
| View run history | `GetByMonitorSpecAsync` |
| Check last run | `GetLatestByMonitorSpecAsync` |
| Find failures | `GetByStatusAsync(Failed)` |
| Dashboard | `GetStatisticsAsync` |
| Cleanup job | `CleanupOldRunsAsync` |

## Acceptance Criteria

- [ ] `IMonitorRunRepository` interface created
- [ ] `EfMonitorRunRepository` implementation created (`internal sealed` with primary constructor)
- [ ] All CRUD methods implemented
- [ ] No `GetByStoreAsync` method (removed)
- [ ] `MarkCompletedAsync` does NOT take `alertEventId` parameter
- [ ] Query methods with proper ordering (newest first)
- [ ] `GetStatisticsAsync` returns aggregated metrics
- [ ] `CleanupOldRunsAsync` preserves minimum runs per spec
- [ ] Partial update methods use `ExecuteUpdateAsync`
- [ ] No references to `StoreGuid` or `AlertEventId` (single FK)
- [ ] Repository registered in DI
- [ ] Solution builds without errors

## Notes

- Consider adding pagination for large result sets
- Statistics query may be slow on large tables - consider caching
- Cleanup should run periodically (e.g., daily via worker)
- May want to add `GetPendingRunsAsync` for orphan detection
