Spensara Spensara Knowledge Base & Documentation Repository

Repository Docs & Knowledge Base

Consolidated Spensara documentation repository (722 Markdown & architecture documents in ../repository)
Built Word Documents (.docx): Spensara Overview (.docx) Capability Assessment (.docx) Brink POS Ingestion Analysis (.docx) The Borg Ingestion Architecture (.docx)

Implementation Specs (157)

Detailed engineering and feature implementation specifications.
implementation-specs/specs/AnomalyDetection/TASK4.4.md 13,281 bytes · Updated Oct 23, 2026 10:44 AM EDT
Raw File

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 CaseMethod
View run historyGetByMonitorSpecAsync
Check last runGetLatestByMonitorSpecAsync
Find failuresGetByStatusAsync(Failed)
DashboardGetStatisticsAsync
Cleanup jobCleanupOldRunsAsync

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