save progress

This commit is contained in:
StellaOps Bot
2025-12-18 09:10:36 +02:00
parent b4235c134c
commit 28823a8960
169 changed files with 11995 additions and 449 deletions

View File

@@ -0,0 +1,115 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scheduler.Storage.Postgres.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
using StellaOps.Scheduler.WebService.Auth;
namespace StellaOps.Scheduler.WebService.FailureSignatures;
internal static class FailureSignatureEndpoints
{
private const string ReadScope = "scheduler.runs.read";
public static IEndpointRouteBuilder MapFailureSignatureEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/scheduler/failure-signatures");
group.MapGet("/best-match", GetBestMatchAsync);
return routes;
}
private static async Task<IResult> GetBestMatchAsync(
HttpContext httpContext,
[FromQuery] string? scopeType,
[FromQuery] string? scopeId,
[FromQuery] string? toolchainHash,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] IScopeAuthorizer scopeAuthorizer,
[FromServices] IServiceProvider serviceProvider,
CancellationToken cancellationToken)
{
try
{
scopeAuthorizer.EnsureScope(httpContext, ReadScope);
var tenant = tenantAccessor.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(scopeType))
{
throw new ValidationException("scopeType must be provided.");
}
if (string.IsNullOrWhiteSpace(scopeId))
{
throw new ValidationException("scopeId must be provided.");
}
if (!Enum.TryParse<FailureSignatureScopeType>(scopeType.Trim(), ignoreCase: true, out var parsedScopeType))
{
throw new ValidationException($"scopeType '{scopeType}' is not valid.");
}
var repository = serviceProvider.GetService<IFailureSignatureRepository>();
if (repository is null)
{
return Results.Problem(
detail: "Failure signature storage is not configured.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
var match = await repository
.GetBestMatchAsync(
tenant.TenantId,
parsedScopeType,
scopeId.Trim(),
string.IsNullOrWhiteSpace(toolchainHash) ? null : toolchainHash.Trim(),
cancellationToken)
.ConfigureAwait(false);
if (match is null)
{
return Results.NoContent();
}
return Results.Ok(new FailureSignatureBestMatchResponse(match));
}
catch (Exception ex) when (ex is ArgumentException or ValidationException)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private sealed record FailureSignatureBestMatchResponse
{
public FailureSignatureBestMatchResponse(FailureSignatureEntity signature)
{
ArgumentNullException.ThrowIfNull(signature);
SignatureId = signature.SignatureId;
ScopeType = signature.ScopeType.ToString().ToLowerInvariant();
ScopeId = signature.ScopeId;
ToolchainHash = signature.ToolchainHash;
ErrorCode = signature.ErrorCode;
ErrorCategory = signature.ErrorCategory?.ToString().ToLowerInvariant();
PredictedOutcome = signature.PredictedOutcome.ToString().ToLowerInvariant();
ConfidenceScore = signature.ConfidenceScore;
OccurrenceCount = signature.OccurrenceCount;
FirstSeenAt = signature.FirstSeenAt;
LastSeenAt = signature.LastSeenAt;
}
public Guid SignatureId { get; }
public string ScopeType { get; }
public string ScopeId { get; }
public string ToolchainHash { get; }
public string? ErrorCode { get; }
public string? ErrorCategory { get; }
public string PredictedOutcome { get; }
public decimal? ConfidenceScore { get; }
public int OccurrenceCount { get; }
public DateTimeOffset FirstSeenAt { get; }
public DateTimeOffset LastSeenAt { get; }
}
}

View File

@@ -245,8 +245,8 @@ internal static class PolicySimulationEndpointExtensions
var preview = new
{
candidates = inputs.Targets?.Count ?? 0,
estimatedRuns = inputs.Targets?.Count ?? 0,
candidates = inputs.SbomSet.Length,
estimatedRuns = inputs.SbomSet.Length,
message = "preview pending execution; actual diff will be available once job starts"
};

View File

@@ -8,11 +8,13 @@ using StellaOps.Plugin.DependencyInjection;
using StellaOps.Plugin.Hosting;
using StellaOps.Scheduler.WebService.Hosting;
using StellaOps.Scheduler.ImpactIndex;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
using StellaOps.Scheduler.WebService;
using StellaOps.Scheduler.WebService.Auth;
using StellaOps.Scheduler.WebService.EventWebhooks;
using StellaOps.Scheduler.WebService.FailureSignatures;
using StellaOps.Scheduler.WebService.GraphJobs;
using StellaOps.Scheduler.WebService.GraphJobs.Events;
using StellaOps.Scheduler.WebService.Schedules;
@@ -213,6 +215,7 @@ app.MapGraphJobEndpoints();
ResolverJobEndpointExtensions.MapResolverJobEndpoints(app);
app.MapScheduleEndpoints();
app.MapRunEndpoints();
app.MapFailureSignatureEndpoints();
app.MapPolicyRunEndpoints();
app.MapPolicySimulationEndpoints();
app.MapSchedulerEventWebhookEndpoints();

View File

@@ -94,6 +94,17 @@ internal sealed class InMemoryRunRepository : IRunRepository
query = query.Where(run => run.CreatedAt > createdAfter);
}
if (options.Cursor is { } cursor)
{
query = options.SortAscending
? query.Where(run => run.CreatedAt > cursor.CreatedAt ||
(run.CreatedAt == cursor.CreatedAt &&
string.Compare(run.Id, cursor.RunId, StringComparison.Ordinal) > 0))
: query.Where(run => run.CreatedAt < cursor.CreatedAt ||
(run.CreatedAt == cursor.CreatedAt &&
string.Compare(run.Id, cursor.RunId, StringComparison.Ordinal) < 0));
}
query = options.SortAscending
? query.OrderBy(run => run.CreatedAt).ThenBy(run => run.Id, StringComparer.Ordinal)
: query.OrderByDescending(run => run.CreatedAt).ThenByDescending(run => run.Id, StringComparer.Ordinal);

View File

@@ -12,6 +12,7 @@ using StellaOps.Scheduler.ImpactIndex;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
using StellaOps.Scheduler.WebService.Auth;
using StellaOps.Scheduler.WebService.Schedules;
namespace StellaOps.Scheduler.WebService.Runs;

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.WebService.Schedules;
public interface ISchedulerAuditService
{
Task<AuditRecord> WriteAsync(SchedulerAuditEvent auditEvent, CancellationToken cancellationToken = default);
}
public sealed record SchedulerAuditEvent(
string TenantId,
string Category,
string Action,
AuditActor Actor,
DateTimeOffset? OccurredAt = null,
string? AuditId = null,
string? EntityId = null,
string? ScheduleId = null,
string? RunId = null,
string? CorrelationId = null,
IReadOnlyDictionary<string, string>? Metadata = null,
string? Message = null);

View File

@@ -0,0 +1,6 @@
# Active Tasks
| ID | Status | Owner(s) | Depends on | Description | Notes |
|----|--------|----------|------------|-------------|-------|
| SCHED-WS-TTFS-0341-T4 | DONE (2025-12-18) | Agent | `docs/implplan/SPRINT_0341_0001_0001_ttfs_enhancements.md` | Add failure signature best-match endpoint to support TTFS FirstSignal enrichment. | `GET /api/v1/scheduler/failure-signatures/best-match` + deterministic endpoint tests. |

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Parsed cursor used for deterministic pagination of scheduler runs.
/// </summary>
public readonly record struct RunListCursor
{
public RunListCursor(DateTimeOffset createdAt, string runId)
{
CreatedAt = Validation.NormalizeTimestamp(createdAt);
RunId = Validation.EnsureId(runId, nameof(runId));
}
public DateTimeOffset CreatedAt { get; }
public string RunId { get; }
}

View File

@@ -1,5 +1,67 @@
-- Scheduler graph jobs schema (Postgres)
-- Legacy compatibility:
-- Earlier schema revisions shipped `scheduler.graph_jobs` as a TEXT/column-based table in `001_initial_schema.sql`.
-- This migration introduces a new JSON-payload based model with a `type` column and will fail on fresh installs
-- unless we either migrate or rename the legacy table first.
DO $$
DECLARE
rec RECORD;
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'scheduler'
AND table_name = 'graph_jobs'
) AND NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'scheduler'
AND table_name = 'graph_jobs'
AND column_name = 'type'
) THEN
-- Rename legacy table so we can create the v2 shape under the canonical name.
ALTER TABLE scheduler.graph_jobs RENAME TO graph_jobs_legacy;
-- Rename legacy constraints to avoid name collisions with the new table (e.g. graph_jobs_pkey).
FOR rec IN
SELECT c.conname
FROM pg_constraint c
JOIN pg_class rel ON rel.oid = c.conrelid
JOIN pg_namespace n ON n.oid = rel.relnamespace
WHERE n.nspname = 'scheduler'
AND rel.relname = 'graph_jobs_legacy'
LOOP
IF rec.conname LIKE 'graph_jobs%' THEN
EXECUTE format(
'ALTER TABLE scheduler.graph_jobs_legacy RENAME CONSTRAINT %I TO %I',
rec.conname,
replace(rec.conname, 'graph_jobs', 'graph_jobs_legacy'));
END IF;
END LOOP;
-- Rename legacy indexes to avoid collisions (idx_graph_jobs_* and graph_jobs_pkey).
FOR rec IN
SELECT indexname
FROM pg_indexes
WHERE schemaname = 'scheduler'
AND tablename = 'graph_jobs_legacy'
LOOP
IF rec.indexname = 'graph_jobs_pkey' THEN
EXECUTE format(
'ALTER INDEX scheduler.%I RENAME TO %I',
rec.indexname,
'graph_jobs_legacy_pkey');
ELSIF rec.indexname LIKE 'idx_graph_jobs%' THEN
EXECUTE format(
'ALTER INDEX scheduler.%I RENAME TO %I',
rec.indexname,
replace(rec.indexname, 'idx_graph_jobs', 'idx_graph_jobs_legacy'));
END IF;
END LOOP;
END IF;
END $$;
DO $$ BEGIN
CREATE TYPE scheduler.graph_job_type AS ENUM ('build', 'overlay');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

View File

@@ -28,7 +28,7 @@ public sealed class DistributedLockRepository : RepositoryBase<SchedulerDataSour
holder_id = EXCLUDED.holder_id,
tenant_id = EXCLUDED.tenant_id,
acquired_at = NOW(),
expires_at = NOW() + EXCLUDED.expires_at - EXCLUDED.acquired_at
expires_at = NOW() + @duration
WHERE scheduler.locks.expires_at < NOW()
""";

View File

@@ -8,6 +8,7 @@ public sealed class RunQueryOptions
public string? ScheduleId { get; init; }
public ImmutableArray<RunState> States { get; init; } = ImmutableArray<RunState>.Empty;
public DateTimeOffset? CreatedAfter { get; init; }
public RunListCursor? Cursor { get; init; }
public bool SortAscending { get; init; } = false;
public int? Limit { get; init; }
}

View File

@@ -100,6 +100,13 @@ LIMIT 1;
filters.Add("created_at > @CreatedAfter");
}
if (options.Cursor is { } cursor)
{
filters.Add(options.SortAscending
? "(created_at, id) > (@CursorCreatedAt, @CursorId)"
: "(created_at, id) < (@CursorCreatedAt, @CursorId)");
}
var order = options.SortAscending ? "created_at ASC, id ASC" : "created_at DESC, id DESC";
var limit = options.Limit.GetValueOrDefault(50);
@@ -117,6 +124,8 @@ LIMIT @Limit;
ScheduleId = options.ScheduleId,
States = options.States.Select(s => s.ToString().ToLowerInvariant()).ToArray(),
CreatedAfter = options.CreatedAfter?.UtcDateTime,
CursorCreatedAt = options.Cursor?.CreatedAt.UtcDateTime,
CursorId = options.Cursor?.RunId,
Limit = limit
});

View File

@@ -3,5 +3,6 @@ namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
public sealed class ScheduleQueryOptions
{
public bool IncludeDisabled { get; init; } = false;
public bool IncludeDeleted { get; init; } = false;
public int? Limit { get; init; }
}

View File

@@ -93,9 +93,14 @@ LIMIT 1;
options ??= new ScheduleQueryOptions();
await using var conn = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken);
var where = options.IncludeDisabled
? "tenant_id = @TenantId AND deleted_at IS NULL"
: "tenant_id = @TenantId AND deleted_at IS NULL AND enabled = TRUE";
var where = options.IncludeDeleted
? "tenant_id = @TenantId"
: "tenant_id = @TenantId AND deleted_at IS NULL";
if (!options.IncludeDisabled)
{
where += " AND enabled = TRUE";
}
var limit = options.Limit.GetValueOrDefault(200);

View File

@@ -39,6 +39,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IScheduleRepository, ScheduleRepository>();
services.AddScoped<IImpactSnapshotRepository, ImpactSnapshotRepository>();
services.AddScoped<IPolicyRunJobRepository, PolicyRunJobRepository>();
services.AddScoped<IFailureSignatureRepository, FailureSignatureRepository>();
services.AddSingleton<IRunSummaryService, RunSummaryService>();
return services;
@@ -65,6 +66,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IJobHistoryRepository, JobHistoryRepository>();
services.AddScoped<IMetricsRepository, MetricsRepository>();
services.AddScoped<IGraphJobRepository, GraphJobRepository>();
services.AddScoped<IFailureSignatureRepository, FailureSignatureRepository>();
return services;
}

View File

@@ -3,8 +3,10 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Postgres;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
using Xunit;
@@ -34,18 +36,6 @@ public sealed class GraphJobRepositoryTests : IAsyncLifetime
trigger: GraphBuildJobTrigger.SbomVersion,
createdAt: DateTimeOffset.UtcNow);
private static GraphOverlayJob OverlayJob(string tenant, string id, GraphJobStatus status = GraphJobStatus.Pending)
=> new(
id: id,
tenantId: tenant,
graphSnapshotId: "snap-1",
status: status,
createdAt: DateTimeOffset.UtcNow,
attempts: 0,
targetGraphId: "graph-1",
correlationId: null,
metadata: null);
[Fact]
public async Task InsertAndGetBuildJob()
{
@@ -117,7 +107,7 @@ public sealed class GraphJobRepositoryTests : IAsyncLifetime
private SchedulerDataSource CreateDataSource()
{
var options = _fixture.Fixture.CreateOptions();
options.SchemaName = _fixture.SchemaName;
return new SchedulerDataSource(Options.Create(options));
options.SchemaName = SchedulerDataSource.DefaultSchemaName;
return new SchedulerDataSource(Options.Create(options), NullLogger<SchedulerDataSource>.Instance);
}
}

View File

@@ -1,4 +1,5 @@
using System.Reflection;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.Scheduler.Storage.Postgres;
using Xunit;
@@ -15,6 +16,44 @@ public sealed class SchedulerPostgresFixture : PostgresIntegrationFixture, IColl
=> typeof(SchedulerDataSource).Assembly;
protected override string GetModuleName() => "Scheduler";
public new async Task TruncateAllTablesAsync(CancellationToken cancellationToken = default)
{
// Base fixture truncates the randomly-generated test schema (e.g. schema_migrations table lives there).
await Fixture.TruncateAllTablesAsync(cancellationToken).ConfigureAwait(false);
// Scheduler migrations create the canonical `scheduler.*` schema explicitly, so we must truncate it as well
// to ensure test isolation between methods.
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
const string listTablesSql = """
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'scheduler'
AND table_type = 'BASE TABLE';
""";
var tables = new List<string>();
await using (var command = new NpgsqlCommand(listTablesSql, connection))
await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
{
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
tables.Add(reader.GetString(0));
}
}
if (tables.Count == 0)
{
return;
}
var qualified = tables.Select(static t => $"scheduler.\"{t}\"");
var truncateSql = $"TRUNCATE TABLE {string.Join(", ", qualified)} RESTART IDENTITY CASCADE;";
await using var truncateCommand = new NpgsqlCommand(truncateSql, connection);
await truncateCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <summary>

View File

@@ -12,17 +12,7 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Update="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Update="xunit" Version="2.9.2" />
<PackageReference Update="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Update="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,155 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scheduler.Storage.Postgres.Models;
using StellaOps.Scheduler.Storage.Postgres.Repositories;
namespace StellaOps.Scheduler.WebService.Tests;
public sealed class FailureSignatureEndpointTests : IClassFixture<SchedulerWebApplicationFactory>
{
private readonly SchedulerWebApplicationFactory _factory;
public FailureSignatureEndpointTests(SchedulerWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task BestMatch_WhenMissing_ReturnsNoContent()
{
var repository = new StubFailureSignatureRepository(match: null);
using var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.RemoveAll<IFailureSignatureRepository>();
services.AddSingleton<IFailureSignatureRepository>(repository);
}));
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-failure-signatures");
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.runs.read");
var response = await client.GetAsync("/api/v1/scheduler/failure-signatures/best-match?scopeType=repo&scopeId=acme/repo&toolchainHash=tch_123");
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.NotNull(repository.LastCall);
Assert.Equal(FailureSignatureScopeType.Repo, repository.LastCall!.Value.ScopeType);
Assert.Equal("acme/repo", repository.LastCall!.Value.ScopeId);
Assert.Equal("tch_123", repository.LastCall!.Value.ToolchainHash);
}
[Fact]
public async Task BestMatch_WhenPresent_ReturnsPayload()
{
var signatureId = Guid.Parse("e22132b0-2aa7-4cde-94a9-0b335d321c61");
var firstSeen = new DateTimeOffset(2025, 12, 18, 10, 0, 0, TimeSpan.Zero);
var lastSeen = firstSeen.AddMinutes(5);
var signature = new FailureSignatureEntity
{
SignatureId = signatureId,
TenantId = "tenant-failure-signatures",
ScopeType = FailureSignatureScopeType.Repo,
ScopeId = "acme/repo",
ToolchainHash = "tch_123",
ErrorCode = "E123",
ErrorCategory = ErrorCategory.Network,
OccurrenceCount = 7,
FirstSeenAt = firstSeen,
LastSeenAt = lastSeen,
PredictedOutcome = PredictedOutcome.Fail,
ConfidenceScore = 0.85m
};
var repository = new StubFailureSignatureRepository(signature);
using var factory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureServices(services =>
{
services.RemoveAll<IFailureSignatureRepository>();
services.AddSingleton<IFailureSignatureRepository>(repository);
}));
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-failure-signatures");
client.DefaultRequestHeaders.Add("X-Scopes", "scheduler.runs.read");
var response = await client.GetAsync("/api/v1/scheduler/failure-signatures/best-match?scopeType=repo&scopeId=acme/repo&toolchainHash=tch_123");
response.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(signatureId, payload.GetProperty("signatureId").GetGuid());
Assert.Equal("repo", payload.GetProperty("scopeType").GetString());
Assert.Equal("acme/repo", payload.GetProperty("scopeId").GetString());
Assert.Equal("tch_123", payload.GetProperty("toolchainHash").GetString());
Assert.Equal("E123", payload.GetProperty("errorCode").GetString());
Assert.Equal("network", payload.GetProperty("errorCategory").GetString());
Assert.Equal("fail", payload.GetProperty("predictedOutcome").GetString());
Assert.Equal(7, payload.GetProperty("occurrenceCount").GetInt32());
Assert.Equal(0.85m, payload.GetProperty("confidenceScore").GetDecimal());
Assert.Equal(firstSeen, payload.GetProperty("firstSeenAt").GetDateTimeOffset());
Assert.Equal(lastSeen, payload.GetProperty("lastSeenAt").GetDateTimeOffset());
}
private sealed class StubFailureSignatureRepository : IFailureSignatureRepository
{
private readonly FailureSignatureEntity? _match;
public StubFailureSignatureRepository(FailureSignatureEntity? match)
{
_match = match;
}
public (string TenantId, FailureSignatureScopeType ScopeType, string ScopeId, string? ToolchainHash)? LastCall { get; private set; }
public Task<FailureSignatureEntity> CreateAsync(FailureSignatureEntity signature, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<FailureSignatureEntity?> GetByIdAsync(string tenantId, Guid signatureId, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<FailureSignatureEntity?> GetByKeyAsync(string tenantId, FailureSignatureScopeType scopeType, string scopeId, string toolchainHash, string? errorCode, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<IReadOnlyList<FailureSignatureEntity>> GetByScopeAsync(string tenantId, FailureSignatureScopeType scopeType, string scopeId, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<IReadOnlyList<FailureSignatureEntity>> GetUnresolvedAsync(string tenantId, int limit = 100, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<IReadOnlyList<FailureSignatureEntity>> GetByPredictedOutcomeAsync(string tenantId, PredictedOutcome outcome, decimal minConfidence = 0.5m, int limit = 100, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<FailureSignatureEntity> UpsertOccurrenceAsync(string tenantId, FailureSignatureScopeType scopeType, string scopeId, string toolchainHash, string? errorCode, ErrorCategory? errorCategory, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<bool> UpdateResolutionAsync(string tenantId, Guid signatureId, ResolutionStatus status, string? notes, string? resolvedBy, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<bool> UpdatePredictionAsync(string tenantId, Guid signatureId, PredictedOutcome outcome, decimal confidence, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<bool> DeleteAsync(string tenantId, Guid signatureId, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<int> PruneResolvedAsync(string tenantId, TimeSpan olderThan, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<FailureSignatureEntity?> GetBestMatchAsync(
string tenantId,
FailureSignatureScopeType scopeType,
string scopeId,
string? toolchainHash = null,
CancellationToken cancellationToken = default)
{
LastCall = (tenantId, scopeType, scopeId, toolchainHash);
return Task.FromResult(_match);
}
}
}