Validate runtime receipt linkage
This commit is contained in:
parent
f702a708d9
commit
f58a0cbc1b
8 changed files with 467 additions and 14 deletions
|
|
@ -15,6 +15,9 @@ public sealed class AgentDispatchRunService(
|
|||
Guid? startedByUserAccountId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateStartRequest(request);
|
||||
await EnsureLinkedTransportRequestAsync(request, cancellationToken);
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var run = new AgentDispatchRun
|
||||
{
|
||||
|
|
@ -126,6 +129,76 @@ public sealed class AgentDispatchRunService(
|
|||
return AgentDispatchRunResult.From(run);
|
||||
}
|
||||
|
||||
private async Task EnsureLinkedTransportRequestAsync(
|
||||
AgentDispatchRunStartRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var linkedReceipt = await dbContext.AgentTransportReceipts
|
||||
.AsNoTracking()
|
||||
.Where(x => x.RequestId == request.RequestId)
|
||||
.OrderByDescending(x => x.OccurredAtUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (linkedReceipt is null)
|
||||
{
|
||||
throw new AgentDispatchRunException(
|
||||
$"Dispatch runs require an existing request-lane receipt for request {request.RequestId}.");
|
||||
}
|
||||
|
||||
EnsureSameIdentity(
|
||||
"dispatch run target repo",
|
||||
request.TargetRepoName,
|
||||
"request-lane target repo",
|
||||
linkedReceipt.TargetRepoName);
|
||||
EnsureSameIdentity(
|
||||
"dispatch run target repository",
|
||||
request.TargetRepositoryFullName,
|
||||
"request-lane target repository",
|
||||
linkedReceipt.TargetRepositoryFullName);
|
||||
EnsureSameIdentity(
|
||||
"dispatch run target agent",
|
||||
request.TargetAgentIdentity,
|
||||
"request-lane target agent",
|
||||
linkedReceipt.TargetAgentIdentity);
|
||||
}
|
||||
|
||||
private static void ValidateStartRequest(AgentDispatchRunStartRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RequestId))
|
||||
{
|
||||
throw new AgentDispatchRunException("Dispatch runs require a request id.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.TargetRepoName))
|
||||
{
|
||||
throw new AgentDispatchRunException("Dispatch runs require a target repo name.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.LaunchMode))
|
||||
{
|
||||
throw new AgentDispatchRunException("Dispatch runs require a launch mode.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSameIdentity(
|
||||
string currentLabel,
|
||||
string currentValue,
|
||||
string linkedLabel,
|
||||
string linkedValue)
|
||||
{
|
||||
var normalizedCurrent = NormalizeText(currentValue);
|
||||
var normalizedLinked = NormalizeText(linkedValue);
|
||||
if (string.IsNullOrWhiteSpace(normalizedCurrent) || string.IsNullOrWhiteSpace(normalizedLinked))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(normalizedCurrent, normalizedLinked, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new AgentDispatchRunException($"{currentLabel} does not match the linked {linkedLabel}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeText(string? value) => value?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
|
|
@ -194,3 +267,5 @@ public sealed record AgentDispatchRunResult(
|
|||
run.UpdatedAtUtc,
|
||||
run.CompletedAtUtc);
|
||||
}
|
||||
|
||||
public sealed class AgentDispatchRunException(string message) : InvalidOperationException(message);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Bifrost.Web.Features.Bridge;
|
||||
|
||||
|
|
@ -14,6 +15,9 @@ public sealed class AgentTransportReceiptService(
|
|||
Guid? actorUserAccountId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateRequest(request);
|
||||
await EnsureConsistentIdentityAsync(request, cancellationToken);
|
||||
|
||||
var receipt = new AgentTransportReceipt
|
||||
{
|
||||
RequestId = NormalizeText(request.RequestId),
|
||||
|
|
@ -42,6 +46,103 @@ public sealed class AgentTransportReceiptService(
|
|||
return AgentTransportReceiptResult.From(receipt);
|
||||
}
|
||||
|
||||
private async Task EnsureConsistentIdentityAsync(
|
||||
AgentTransportReceiptRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var priorReceipt = await dbContext.AgentTransportReceipts
|
||||
.AsNoTracking()
|
||||
.Where(x => x.RequestId == request.RequestId)
|
||||
.OrderByDescending(x => x.OccurredAtUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (priorReceipt is not null)
|
||||
{
|
||||
EnsureSameIdentity(
|
||||
"request-lane target repo",
|
||||
request.TargetRepoName,
|
||||
"prior request-lane target repo",
|
||||
priorReceipt.TargetRepoName);
|
||||
EnsureSameIdentity(
|
||||
"request-lane target repository",
|
||||
request.TargetRepositoryFullName,
|
||||
"prior request-lane target repository",
|
||||
priorReceipt.TargetRepositoryFullName);
|
||||
EnsureSameIdentity(
|
||||
"request-lane target agent",
|
||||
request.TargetAgentIdentity,
|
||||
"prior request-lane target agent",
|
||||
priorReceipt.TargetAgentIdentity);
|
||||
}
|
||||
|
||||
var linkedRun = await dbContext.AgentDispatchRuns
|
||||
.AsNoTracking()
|
||||
.Where(x => x.RequestId == request.RequestId)
|
||||
.OrderByDescending(x => x.StartedAtUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (linkedRun is not null)
|
||||
{
|
||||
EnsureSameIdentity(
|
||||
"request-lane target repo",
|
||||
request.TargetRepoName,
|
||||
"dispatch-run target repo",
|
||||
linkedRun.TargetRepoName);
|
||||
EnsureSameIdentity(
|
||||
"request-lane target repository",
|
||||
request.TargetRepositoryFullName,
|
||||
"dispatch-run target repository",
|
||||
linkedRun.TargetRepositoryFullName);
|
||||
EnsureSameIdentity(
|
||||
"request-lane target agent",
|
||||
request.TargetAgentIdentity,
|
||||
"dispatch-run target agent",
|
||||
linkedRun.TargetAgentIdentity);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateRequest(AgentTransportReceiptRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RequestId))
|
||||
{
|
||||
throw new AgentTransportReceiptException("Request-lane receipts require a request id.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
throw new AgentTransportReceiptException("Request-lane receipts require a title.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.TargetRepoName))
|
||||
{
|
||||
throw new AgentTransportReceiptException("Request-lane receipts require a target repo name.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ActorName))
|
||||
{
|
||||
throw new AgentTransportReceiptException("Request-lane receipts require an actor name.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSameIdentity(
|
||||
string currentLabel,
|
||||
string currentValue,
|
||||
string linkedLabel,
|
||||
string linkedValue)
|
||||
{
|
||||
var normalizedCurrent = NormalizeText(currentValue);
|
||||
var normalizedLinked = NormalizeText(linkedValue);
|
||||
if (string.IsNullOrWhiteSpace(normalizedCurrent) || string.IsNullOrWhiteSpace(normalizedLinked))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(normalizedCurrent, normalizedLinked, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new AgentTransportReceiptException($"{currentLabel} does not match the linked {linkedLabel}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildAuditDetail(AgentTransportReceipt receipt)
|
||||
{
|
||||
var actorName = string.IsNullOrWhiteSpace(receipt.ActorName) ? "unknown actor" : receipt.ActorName;
|
||||
|
|
@ -89,3 +190,5 @@ public sealed record AgentTransportReceiptResult(
|
|||
receipt.Note,
|
||||
receipt.OccurredAtUtc);
|
||||
}
|
||||
|
||||
public sealed class AgentTransportReceiptException(string message) : InvalidOperationException(message);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Bifrost.Web.Features.Bridge;
|
||||
|
||||
|
|
@ -14,6 +15,9 @@ public sealed class GovernanceActivityReceiptService(
|
|||
Guid? actorUserAccountId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateRequest(request);
|
||||
await EnsureLinkedDispatchRequestAsync(request, cancellationToken);
|
||||
|
||||
var receipt = new GovernanceActivityReceipt
|
||||
{
|
||||
TopicId = NormalizeText(request.TopicId),
|
||||
|
|
@ -43,6 +47,86 @@ public sealed class GovernanceActivityReceiptService(
|
|||
return GovernanceActivityReceiptResult.From(receipt);
|
||||
}
|
||||
|
||||
private async Task EnsureLinkedDispatchRequestAsync(
|
||||
GovernanceActivityReceiptRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.DispatchRequestId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var linkedReceipt = await dbContext.AgentTransportReceipts
|
||||
.AsNoTracking()
|
||||
.Where(x => x.RequestId == request.DispatchRequestId)
|
||||
.OrderByDescending(x => x.OccurredAtUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (linkedReceipt is null)
|
||||
{
|
||||
throw new GovernanceActivityReceiptException(
|
||||
$"Governance receipt references unknown dispatch request {request.DispatchRequestId}.");
|
||||
}
|
||||
|
||||
EnsureSameIdentity(
|
||||
"governance jurisdiction repo",
|
||||
request.JurisdictionRepoName,
|
||||
"dispatch request repo",
|
||||
linkedReceipt.TargetRepoName);
|
||||
EnsureSameIdentity(
|
||||
"governance jurisdiction agent",
|
||||
request.JurisdictionAgentIdentity,
|
||||
"dispatch request agent",
|
||||
linkedReceipt.TargetAgentIdentity);
|
||||
}
|
||||
|
||||
private static void ValidateRequest(GovernanceActivityReceiptRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.TopicId))
|
||||
{
|
||||
throw new GovernanceActivityReceiptException("Governance receipts require a topic id.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
throw new GovernanceActivityReceiptException("Governance receipts require a title.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.JurisdictionRepoName))
|
||||
{
|
||||
throw new GovernanceActivityReceiptException("Governance receipts require a jurisdiction repo name.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ActorKind))
|
||||
{
|
||||
throw new GovernanceActivityReceiptException("Governance receipts require an actor kind.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ActorName))
|
||||
{
|
||||
throw new GovernanceActivityReceiptException("Governance receipts require an actor name.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSameIdentity(
|
||||
string currentLabel,
|
||||
string currentValue,
|
||||
string linkedLabel,
|
||||
string linkedValue)
|
||||
{
|
||||
var normalizedCurrent = NormalizeText(currentValue);
|
||||
var normalizedLinked = NormalizeText(linkedValue);
|
||||
if (string.IsNullOrWhiteSpace(normalizedCurrent) || string.IsNullOrWhiteSpace(normalizedLinked))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(normalizedCurrent, normalizedLinked, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new GovernanceActivityReceiptException($"{currentLabel} does not match the linked {linkedLabel}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ToAuditSuffix(GovernanceActivityReceiptKind activityKind) => activityKind switch
|
||||
{
|
||||
GovernanceActivityReceiptKind.TopicOpened => "topic-opened",
|
||||
|
|
@ -102,3 +186,5 @@ public sealed record GovernanceActivityReceiptResult(
|
|||
receipt.Note,
|
||||
receipt.OccurredAtUtc);
|
||||
}
|
||||
|
||||
public sealed class GovernanceActivityReceiptException(string message) : InvalidOperationException(message);
|
||||
|
|
|
|||
|
|
@ -490,8 +490,15 @@ dispatchRuns.MapPost("/start", async (
|
|||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var result = await agentDispatchRunService.StartAsync(command, null, cancellationToken);
|
||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
||||
try
|
||||
{
|
||||
var result = await agentDispatchRunService.StartAsync(command, null, cancellationToken);
|
||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
||||
}
|
||||
catch (AgentDispatchRunException error)
|
||||
{
|
||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
});
|
||||
|
||||
dispatchRuns.MapPost("/{id:guid}/complete", async (
|
||||
|
|
@ -507,10 +514,17 @@ dispatchRuns.MapPost("/{id:guid}/complete", async (
|
|||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var result = await agentDispatchRunService.CompleteAsync(id, command, null, cancellationToken);
|
||||
return result is null
|
||||
? Results.NotFound()
|
||||
: JsonResult(result, appJsonSerializerOptions);
|
||||
try
|
||||
{
|
||||
var result = await agentDispatchRunService.CompleteAsync(id, command, null, cancellationToken);
|
||||
return result is null
|
||||
? Results.NotFound()
|
||||
: JsonResult(result, appJsonSerializerOptions);
|
||||
}
|
||||
catch (AgentDispatchRunException error)
|
||||
{
|
||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
});
|
||||
|
||||
dispatchRuns.MapPost("/{id:guid}/fail", async (
|
||||
|
|
@ -526,10 +540,17 @@ dispatchRuns.MapPost("/{id:guid}/fail", async (
|
|||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var result = await agentDispatchRunService.FailAsync(id, command, null, cancellationToken);
|
||||
return result is null
|
||||
? Results.NotFound()
|
||||
: JsonResult(result, appJsonSerializerOptions);
|
||||
try
|
||||
{
|
||||
var result = await agentDispatchRunService.FailAsync(id, command, null, cancellationToken);
|
||||
return result is null
|
||||
? Results.NotFound()
|
||||
: JsonResult(result, appJsonSerializerOptions);
|
||||
}
|
||||
catch (AgentDispatchRunException error)
|
||||
{
|
||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
});
|
||||
|
||||
var transportReceipts = app.MapGroup("/transport/receipts");
|
||||
|
|
@ -546,8 +567,15 @@ transportReceipts.MapPost("", async (
|
|||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var result = await agentTransportReceiptService.RecordAsync(command, null, cancellationToken);
|
||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
||||
try
|
||||
{
|
||||
var result = await agentTransportReceiptService.RecordAsync(command, null, cancellationToken);
|
||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
||||
}
|
||||
catch (AgentTransportReceiptException error)
|
||||
{
|
||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
});
|
||||
|
||||
var governanceReceipts = app.MapGroup("/governance/receipts");
|
||||
|
|
@ -564,8 +592,15 @@ governanceReceipts.MapPost("", async (
|
|||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var result = await governanceActivityReceiptService.RecordAsync(command, null, cancellationToken);
|
||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
||||
try
|
||||
{
|
||||
var result = await governanceActivityReceiptService.RecordAsync(command, null, cancellationToken);
|
||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
||||
}
|
||||
catch (GovernanceActivityReceiptException error)
|
||||
{
|
||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
});
|
||||
|
||||
var eveGovernance = app.MapGroup("/eve/governance")
|
||||
|
|
|
|||
Loading…
Reference in a new issue