feat(ai): enforce local-first routing

Keep external providers behind server consent, task, and prompt-cost gates while persisting actual provider provenance.
This commit is contained in:
cesnimda
2026-08-09 12:30:11 +02:00
parent c3f4a57195
commit 5eb9b3cb96
29 changed files with 967 additions and 145 deletions
+39 -2
View File
@@ -159,10 +159,23 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
cancellationToken);
}
public async Task<int> CompleteAsync(Guid operationId, string leaseToken, string? resultReference, CancellationToken cancellationToken)
public Task<int> CompleteAsync(Guid operationId, string leaseToken, string? resultReference, CancellationToken cancellationToken)
=> CompleteAsync(operationId, leaseToken, resultReference, null, null, null, cancellationToken);
public async Task<int> CompleteAsync(
Guid operationId,
string leaseToken,
string? resultReference,
string? provider,
string? model,
string? completionStage,
CancellationToken cancellationToken)
{
EnsureOwnerScope();
ValidateOptional(resultReference, 256, nameof(resultReference));
ValidateOptional(provider, 128, nameof(provider));
ValidateOptional(model, 128, nameof(model));
ValidateOptional(completionStage, 64, nameof(completionStage));
var now = UtcNow;
await using var transaction = await BeginTransactionAsync(cancellationToken);
var operation = await db.UserOperations.AsNoTracking().FirstOrDefaultAsync(item => item.Id == operationId, cancellationToken);
@@ -173,6 +186,9 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
.ExecuteUpdateAsync(setters => setters
.SetProperty(operation => operation.Status, OperationStatuses.Succeeded)
.SetProperty(operation => operation.ResultReference, resultReference)
.SetProperty(operation => operation.Provider, provider)
.SetProperty(operation => operation.Model, model)
.SetProperty(operation => operation.ProgressStage, completionStage)
.SetProperty(operation => operation.CompletedAtUtc, now)
.SetProperty(operation => operation.ProgressPercent, 100)
.SetProperty(operation => operation.LeaseToken, (string?)null)
@@ -187,11 +203,27 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
return affected;
}
public async Task<bool> FailAsync(Guid operationId, string leaseToken, bool retryable, string category, string message, TimeSpan retryDelay, CancellationToken cancellationToken)
public Task<bool> FailAsync(Guid operationId, string leaseToken, bool retryable, string category, string message, TimeSpan retryDelay, CancellationToken cancellationToken)
=> FailAsync(operationId, leaseToken, retryable, category, message, retryDelay, null, null, null, cancellationToken);
public async Task<bool> FailAsync(
Guid operationId,
string leaseToken,
bool retryable,
string category,
string message,
TimeSpan retryDelay,
string? provider,
string? model,
string? progressStage,
CancellationToken cancellationToken)
{
EnsureOwnerScope();
ValidateRequired(category, 64, nameof(category));
ValidateRequired(message, 512, nameof(message));
ValidateOptional(provider, 128, nameof(provider));
ValidateOptional(model, 128, nameof(model));
ValidateOptional(progressStage, 64, nameof(progressStage));
if (retryDelay < TimeSpan.Zero || retryDelay > TimeSpan.FromHours(1)) throw new ArgumentOutOfRangeException(nameof(retryDelay));
await using var transaction = await BeginTransactionAsync(cancellationToken);
var operation = await db.UserOperations.AsNoTracking()
@@ -209,6 +241,9 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
.SetProperty(item => item.CompletedAtUtc, canRetry ? null : now)
.SetProperty(item => item.FailureCategory, category)
.SetProperty(item => item.FailureMessage, message)
.SetProperty(item => item.Provider, provider)
.SetProperty(item => item.Model, model)
.SetProperty(item => item.ProgressStage, progressStage)
.SetProperty(item => item.LeaseToken, (string?)null)
.SetProperty(item => item.LeaseExpiresAtUtc, (DateTime?)null),
cancellationToken);
@@ -290,6 +325,8 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
operation.FailureCategory = null;
operation.FailureMessage = null;
operation.ResultReference = null;
operation.Provider = null;
operation.Model = null;
operation.ProgressStage = null;
operation.ProgressPercent = null;
var existingNotification = await db.UserNotifications.FirstOrDefaultAsync(item => item.OperationId == operationId, cancellationToken);