feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticated browser-safe Server-Sent Events projection over the bounded
|
||||
/// Gateway event buffer. The browser never receives Gateway credentials.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/openclaw/events")]
|
||||
public sealed class OpenClawEventsController(IOpenClawEventProjectionService events) : ControllerBase
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(750);
|
||||
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(15);
|
||||
|
||||
[HttpGet]
|
||||
public async Task Stream(
|
||||
[FromQuery] string? lastEventId = null,
|
||||
[FromQuery] bool follow = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status200OK;
|
||||
Response.ContentType = "text/event-stream";
|
||||
Response.Headers.CacheControl = "no-cache, no-store";
|
||||
Response.Headers.Connection = "keep-alive";
|
||||
Response.Headers["X-Accel-Buffering"] = "no";
|
||||
|
||||
var headerCursor = Request.Headers["Last-Event-ID"].FirstOrDefault();
|
||||
var cursor = string.IsNullOrWhiteSpace(headerCursor) ? lastEventId : headerCursor;
|
||||
var lastHeartbeatAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var connectionEvent = events.CreateConnectionEvent(cursor);
|
||||
var connectionSignature = GetConnectionSignature(connectionEvent);
|
||||
await Response.WriteAsync("retry: 2000\n\n", cancellationToken);
|
||||
await WriteEventAsync(connectionEvent, cancellationToken);
|
||||
await Response.Body.FlushAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
do
|
||||
{
|
||||
connectionEvent = events.CreateConnectionEvent(cursor);
|
||||
var currentConnectionSignature = GetConnectionSignature(connectionEvent);
|
||||
if (!string.Equals(
|
||||
connectionSignature,
|
||||
currentConnectionSignature,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
await WriteEventAsync(connectionEvent, cancellationToken);
|
||||
connectionSignature = currentConnectionSignature;
|
||||
}
|
||||
|
||||
var batch = events.Project(cursor);
|
||||
if (batch.ReplayBoundaryMissed)
|
||||
{
|
||||
await WriteEventAsync(CreateGapEvent(cursor, batch), cancellationToken);
|
||||
}
|
||||
|
||||
foreach (var item in batch.Events)
|
||||
{
|
||||
await WriteEventAsync(item, cancellationToken);
|
||||
}
|
||||
|
||||
cursor = batch.Cursor ?? cursor;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (!follow || now - lastHeartbeatAt >= HeartbeatInterval)
|
||||
{
|
||||
await WriteEventAsync(events.CreateHeartbeatEvent(cursor), cancellationToken);
|
||||
lastHeartbeatAt = now;
|
||||
}
|
||||
|
||||
await Response.Body.FlushAsync(cancellationToken);
|
||||
if (!follow)
|
||||
break;
|
||||
|
||||
await Task.Delay(PollInterval, cancellationToken);
|
||||
} while (!cancellationToken.IsCancellationRequested);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Expected when EventSource disconnects or the request is aborted.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteEventAsync(
|
||||
OpenClawStreamEventDto item,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var id = StripSseControlCharacters(item.Id);
|
||||
var eventType = StripSseControlCharacters(item.Type);
|
||||
var data = JsonSerializer.Serialize(item, JsonOptions);
|
||||
await Response.WriteAsync(
|
||||
$"id: {id}\nevent: {eventType}\ndata: {data}\n\n",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static OpenClawStreamEventDto CreateGapEvent(
|
||||
string? requestedCursor,
|
||||
OpenClawEventBatch batch)
|
||||
{
|
||||
var occurredAt = DateTimeOffset.UtcNow;
|
||||
var gapCursor = batch.Events.Count == 0
|
||||
? batch.Cursor ?? "origin"
|
||||
: string.IsNullOrWhiteSpace(requestedCursor) ? "origin" : requestedCursor;
|
||||
return new OpenClawStreamEventDto(
|
||||
gapCursor,
|
||||
"openclaw.gap",
|
||||
"gap",
|
||||
"gateway",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
occurredAt,
|
||||
new JsonObject
|
||||
{
|
||||
["reason"] = "last-event-id-outside-buffer",
|
||||
["requestedLastEventId"] = requestedCursor,
|
||||
["oldestAvailableId"] = batch.OldestAvailableId,
|
||||
["latestAvailableId"] = batch.LatestAvailableId,
|
||||
["requiresAuthoritativeRefresh"] = true
|
||||
});
|
||||
}
|
||||
|
||||
private static string StripSseControlCharacters(string value)
|
||||
=> value.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n", string.Empty, StringComparison.Ordinal);
|
||||
|
||||
private static string GetConnectionSignature(OpenClawStreamEventDto item)
|
||||
=> string.Join(
|
||||
"\u001f",
|
||||
item.Payload?["state"]?.ToJsonString() ?? string.Empty,
|
||||
item.Payload?["connected"]?.ToJsonString() ?? string.Empty,
|
||||
item.Payload?["gatewayVersion"]?.ToJsonString() ?? string.Empty,
|
||||
item.Payload?["protocolVersion"]?.ToJsonString() ?? string.Empty,
|
||||
item.Payload?["deviceId"]?.ToJsonString() ?? string.Empty,
|
||||
item.Payload?["deviceTokenConfigured"]?.ToJsonString() ?? string.Empty,
|
||||
item.Payload?["pairingRequired"]?.ToJsonString() ?? string.Empty,
|
||||
item.Payload?["pairingRequestId"]?.ToJsonString() ?? string.Empty,
|
||||
item.Payload?["lastConnectedAt"]?.ToJsonString() ?? string.Empty,
|
||||
item.Payload?["reconnectAttempts"]?.ToJsonString() ?? string.Empty,
|
||||
item.Payload?["message"]?.ToJsonString() ?? string.Empty);
|
||||
}
|
||||
Reference in New Issue
Block a user