feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Observability;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/events")]
|
||||
public sealed class DomainEventsController(
|
||||
NexusDbContext db,
|
||||
IDomainEventStreamService eventStream,
|
||||
ILogger<DomainEventsController> logger) : ControllerBase
|
||||
{
|
||||
private const int MaximumReplay = 512;
|
||||
|
||||
[HttpGet]
|
||||
public async Task Get(
|
||||
[FromQuery] string? channels,
|
||||
[FromQuery] long? afterSequence,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Response.Headers.ContentType = "text/event-stream";
|
||||
Response.Headers.CacheControl = "no-cache, no-store, must-revalidate";
|
||||
Response.Headers.Connection = "keep-alive";
|
||||
Response.Headers["X-Accel-Buffering"] = "no";
|
||||
|
||||
var requestedChannels = ParseChannels(channels);
|
||||
var cursor = ResolveCursor(afterSequence);
|
||||
await using var subscription = eventStream.Subscribe(requestedChannels);
|
||||
var lastSent = cursor ?? subscription.StartingSequence;
|
||||
|
||||
if (cursor.HasValue)
|
||||
{
|
||||
var earliest = await db.OutboxEvents
|
||||
.AsNoTracking()
|
||||
.Where(item => item.PublishedAt != null)
|
||||
.OrderBy(item => item.Sequence)
|
||||
.Select(item => (long?)item.Sequence)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (earliest.HasValue && cursor.Value < earliest.Value - 1)
|
||||
{
|
||||
await WriteResyncRequiredAsync(
|
||||
cursor.Value,
|
||||
"retention_gap",
|
||||
cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var replay = await db.OutboxEvents
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.PublishedAt != null &&
|
||||
item.Sequence > cursor.Value &&
|
||||
item.Sequence <= subscription.StartingSequence)
|
||||
.OrderBy(item => item.Sequence)
|
||||
.Take(MaximumReplay + 1)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (replay.Count > MaximumReplay)
|
||||
{
|
||||
await WriteResyncRequiredAsync(
|
||||
cursor.Value,
|
||||
"replay_limit",
|
||||
cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in replay)
|
||||
{
|
||||
var domainEvent = DomainEventStreamService.Map(item);
|
||||
if (!MatchesChannel(domainEvent, requestedChannels))
|
||||
continue;
|
||||
await WriteEventAsync("domain", domainEvent.Sequence, domainEvent, cancellationToken);
|
||||
lastSent = domainEvent.Sequence;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
using var iteration = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken);
|
||||
var readTask = subscription.Reader
|
||||
.WaitToReadAsync(iteration.Token)
|
||||
.AsTask();
|
||||
var heartbeatTask = Task.Delay(
|
||||
TimeSpan.FromSeconds(20),
|
||||
iteration.Token);
|
||||
var completed = await Task.WhenAny(readTask, heartbeatTask);
|
||||
if (completed == heartbeatTask)
|
||||
{
|
||||
iteration.Cancel();
|
||||
await WriteEventAsync(
|
||||
"heartbeat",
|
||||
lastSent,
|
||||
new
|
||||
{
|
||||
sequence = eventStream.CurrentSequence,
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
iteration.Cancel();
|
||||
if (!await readTask)
|
||||
break;
|
||||
while (subscription.Reader.TryRead(out var domainEvent))
|
||||
{
|
||||
if (domainEvent.Sequence <= lastSent)
|
||||
continue;
|
||||
await WriteEventAsync(
|
||||
"domain",
|
||||
domainEvent.Sequence,
|
||||
domainEvent,
|
||||
cancellationToken);
|
||||
lastSent = domainEvent.Sequence;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (DomainEventSubscriberOverflowException)
|
||||
{
|
||||
await WriteResyncRequiredAsync(lastSent, "subscriber_overflow", cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Normal browser disconnect.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Domain event stream ended unexpectedly");
|
||||
}
|
||||
}
|
||||
|
||||
private long? ResolveCursor(long? queryCursor)
|
||||
{
|
||||
if (queryCursor.HasValue)
|
||||
return queryCursor.Value >= 0 ? queryCursor : null;
|
||||
if (!Request.Headers.TryGetValue("Last-Event-ID", out var header))
|
||||
return null;
|
||||
return long.TryParse(header.FirstOrDefault(), out var parsed) && parsed >= 0
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task WriteResyncRequiredAsync(
|
||||
long staleSequence,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
NexusTelemetry.SseResyncs.Add(1);
|
||||
// Resume after the latest sequence that was visible when the resync
|
||||
// decision was made. Re-emitting the stale cursor would make a client
|
||||
// reconnect into the same retention/replay gap forever.
|
||||
var resumeSequence = eventStream.CurrentSequence;
|
||||
var payload = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
reason,
|
||||
staleSequence,
|
||||
resumeSequence
|
||||
});
|
||||
var domainEvent = new DomainEventDto(
|
||||
resumeSequence,
|
||||
DomainEventTypes.ResyncRequired,
|
||||
new EntityRefDto("event-stream", "*"),
|
||||
0,
|
||||
DateTimeOffset.UtcNow,
|
||||
payload);
|
||||
await WriteEventAsync(
|
||||
DomainEventTypes.ResyncRequired,
|
||||
resumeSequence,
|
||||
domainEvent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task WriteEventAsync(
|
||||
string eventName,
|
||||
long sequence,
|
||||
object payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Response.WriteAsync($"id: {sequence}\n", cancellationToken);
|
||||
await Response.WriteAsync($"event: {eventName}\n", cancellationToken);
|
||||
await Response.WriteAsync(
|
||||
$"data: {JsonSerializer.Serialize(payload, JsonOptions)}\n\n",
|
||||
cancellationToken);
|
||||
await Response.Body.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions =
|
||||
new(JsonSerializerDefaults.Web);
|
||||
|
||||
private static IReadOnlySet<string> ParseChannels(string? channels)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(channels))
|
||||
return new HashSet<string>(["*"], StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var parsed = channels
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(channel => channel.ToLowerInvariant())
|
||||
.Where(channel => channel.All(character =>
|
||||
char.IsLetterOrDigit(character) || character is '-' or '_'))
|
||||
.Take(16)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return parsed.Count == 0
|
||||
? new HashSet<string>(["*"], StringComparer.OrdinalIgnoreCase)
|
||||
: parsed;
|
||||
}
|
||||
|
||||
private static bool MatchesChannel(
|
||||
DomainEventDto domainEvent,
|
||||
IReadOnlySet<string> channels)
|
||||
{
|
||||
if (channels.Contains("*"))
|
||||
return true;
|
||||
var channel = domainEvent.Entity.Type switch
|
||||
{
|
||||
"agent-proposal" => "agents",
|
||||
"task" => "tasks",
|
||||
"run" => "runs",
|
||||
"cron" => "cron",
|
||||
_ => $"{domainEvent.Entity.Type}s"
|
||||
};
|
||||
return channels.Contains(channel);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user