feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class OpenClawEventProjectionService(IGatewayConnector connector)
|
||||
: IOpenClawEventProjectionService
|
||||
{
|
||||
public OpenClawEventBatch Project(string? lastEventId, int limit = 500)
|
||||
{
|
||||
var projectedAt = DateTimeOffset.UtcNow;
|
||||
var requestedCursor = NormalizeCursor(lastEventId);
|
||||
var envelopes = connector
|
||||
.GetRecentEvents(Math.Clamp(limit, 1, 500))
|
||||
.OrderBy(item => item.ReceivedAt)
|
||||
.ThenBy(item => item.Sequence)
|
||||
.ThenBy(OpenClawEventIdentity.Create, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var allEvents = ProjectEvents(envelopes);
|
||||
var oldestId = allEvents.FirstOrDefault()?.Id;
|
||||
var latestId = allEvents.LastOrDefault()?.Id;
|
||||
var replayBoundaryMissed = false;
|
||||
IReadOnlyList<OpenClawStreamEventDto> selected = allEvents;
|
||||
|
||||
if (requestedCursor is not null)
|
||||
{
|
||||
var cursorIndex = allEvents.FindIndex(item =>
|
||||
string.Equals(item.Id, requestedCursor, StringComparison.Ordinal));
|
||||
if (cursorIndex >= 0)
|
||||
{
|
||||
selected = allEvents.Skip(cursorIndex + 1).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
replayBoundaryMissed = true;
|
||||
}
|
||||
}
|
||||
|
||||
var cursor = selected.LastOrDefault()?.Id
|
||||
?? (replayBoundaryMissed ? latestId ?? "origin" : requestedCursor);
|
||||
|
||||
return new OpenClawEventBatch(
|
||||
selected,
|
||||
cursor,
|
||||
replayBoundaryMissed,
|
||||
oldestId,
|
||||
latestId,
|
||||
projectedAt);
|
||||
}
|
||||
|
||||
public OpenClawStreamEventDto CreateConnectionEvent(string? cursor)
|
||||
{
|
||||
var occurredAt = DateTimeOffset.UtcNow;
|
||||
return new OpenClawStreamEventDto(
|
||||
NormalizeCursor(cursor) ?? "origin",
|
||||
"openclaw.connection",
|
||||
"connection",
|
||||
"connection",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
occurredAt,
|
||||
new JsonObject
|
||||
{
|
||||
["state"] = connector.ConnectionState.ToString().ToLowerInvariant(),
|
||||
["connected"] = connector.ConnectionState == GatewayConnectionState.Connected,
|
||||
["gatewayVersion"] = connector.GatewayVersion,
|
||||
["protocolVersion"] = connector.ProtocolVersion,
|
||||
["deviceId"] = connector.DeviceId,
|
||||
["deviceTokenConfigured"] = connector.DeviceTokenConfigured,
|
||||
["pairingRequired"] = connector.PairingRequired,
|
||||
["pairingRequestId"] = connector.PairingRequestId,
|
||||
["lastConnectedAt"] = connector.LastConnectedAt,
|
||||
["lastEventAt"] = connector.LastEventAt,
|
||||
["reconnectAttempts"] = connector.ReconnectAttempts,
|
||||
["message"] = connector.StatusMessage
|
||||
});
|
||||
}
|
||||
|
||||
public OpenClawStreamEventDto CreateHeartbeatEvent(string? cursor)
|
||||
{
|
||||
var occurredAt = DateTimeOffset.UtcNow;
|
||||
return new OpenClawStreamEventDto(
|
||||
NormalizeCursor(cursor) ?? "origin",
|
||||
"openclaw.heartbeat",
|
||||
"heartbeat",
|
||||
"heartbeat",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
occurredAt,
|
||||
new JsonObject
|
||||
{
|
||||
["connected"] = connector.ConnectionState == GatewayConnectionState.Connected,
|
||||
["lastEventAt"] = connector.LastEventAt,
|
||||
["sentAt"] = occurredAt
|
||||
});
|
||||
}
|
||||
|
||||
private static List<OpenClawStreamEventDto> ProjectEvents(
|
||||
IReadOnlyList<GatewayEventEnvelope> envelopes)
|
||||
{
|
||||
var result = new List<OpenClawStreamEventDto>(envelopes.Count);
|
||||
long? previousSequence = null;
|
||||
|
||||
foreach (var envelope in envelopes)
|
||||
{
|
||||
var sequenceGap = envelope.Sequence.HasValue
|
||||
&& previousSequence.HasValue
|
||||
&& envelope.Sequence.Value > previousSequence.Value + 1;
|
||||
var sequenceReset = envelope.Sequence.HasValue
|
||||
&& previousSequence.HasValue
|
||||
&& envelope.Sequence.Value < previousSequence.Value;
|
||||
var missingFrom = sequenceGap ? previousSequence + 1 : null;
|
||||
var missingTo = sequenceGap ? envelope.Sequence - 1 : null;
|
||||
var category = Classify(envelope.Event, envelope.Payload);
|
||||
|
||||
result.Add(new OpenClawStreamEventDto(
|
||||
OpenClawEventIdentity.Create(envelope),
|
||||
$"openclaw.{category}",
|
||||
envelope.Event,
|
||||
category,
|
||||
envelope.Sequence,
|
||||
envelope.StateVersion,
|
||||
previousSequence,
|
||||
sequenceGap,
|
||||
sequenceReset,
|
||||
missingFrom,
|
||||
missingTo,
|
||||
envelope.ReceivedAt,
|
||||
OpenClawPayloadSanitizer.Redact(envelope.Payload)));
|
||||
|
||||
if (envelope.Sequence.HasValue)
|
||||
previousSequence = envelope.Sequence;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Classify(string eventName, JsonNode? payload)
|
||||
{
|
||||
var normalized = eventName.ToLowerInvariant();
|
||||
if (normalized.Contains("approval", StringComparison.Ordinal))
|
||||
return "approval";
|
||||
if (normalized.Contains("artifact", StringComparison.Ordinal))
|
||||
return "artifact";
|
||||
if (normalized.Contains("tool", StringComparison.Ordinal))
|
||||
return "tool";
|
||||
if (normalized.Contains("session", StringComparison.Ordinal))
|
||||
return "session";
|
||||
if (normalized is "chat" or "agent"
|
||||
|| normalized.Contains("run", StringComparison.Ordinal)
|
||||
|| payload?["runId"] is not null)
|
||||
{
|
||||
return "run";
|
||||
}
|
||||
|
||||
return "gateway";
|
||||
}
|
||||
|
||||
private static string? NormalizeCursor(string? cursor)
|
||||
{
|
||||
var trimmed = cursor?.Trim();
|
||||
return string.IsNullOrWhiteSpace(trimmed)
|
||||
|| string.Equals(trimmed, "origin", StringComparison.Ordinal)
|
||||
? null
|
||||
: trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class OpenClawEventIdentity
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static string Create(GatewayEventEnvelope envelope)
|
||||
{
|
||||
var payload = envelope.Payload?.ToJsonString(JsonOptions) ?? "null";
|
||||
var fingerprint = string.Join(
|
||||
"\u001f",
|
||||
envelope.Event,
|
||||
envelope.ReceivedAt.UtcDateTime.Ticks,
|
||||
envelope.Sequence?.ToString() ?? string.Empty,
|
||||
envelope.StateVersion?.ToString() ?? string.Empty,
|
||||
payload);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(fingerprint));
|
||||
return $"gw-{envelope.ReceivedAt.UtcDateTime.Ticks:x16}-{Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user