Files
nexus/backend/Services/OpenClawRunGateway.cs
T
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

272 lines
9.0 KiB
C#

using System.Text.Json.Nodes;
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
/// <summary>
/// Small capability-gated adapter around the protocol connector. Keeping
/// invocation metadata at this seam lets the connector add W3C trace context
/// without leaking Gateway protocol details into the run domain service.
/// </summary>
public sealed class OpenClawRunGateway(
IGatewayConnector connector,
IOpenClawWriteGate writeGate) : IOpenClawRunGateway
{
public async Task<OpenClawRunGatewayResult> StartAsync(
OpenClawRun run,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
{
var writeDecision = await writeGate.EvaluateAsync(
"chat.send",
"operator.write",
cancellationToken);
if (!writeDecision.Allowed)
{
return new OpenClawRunGatewayResult(
false,
false,
OpenClawRunStates.Blocked,
writeDecision.Recovery is null
? writeDecision.Message
: $"{writeDecision.Message} {writeDecision.Recovery}");
}
if (connector.ConnectionState != GatewayConnectionState.Connected)
{
return new OpenClawRunGatewayResult(
true,
false,
OpenClawRunStates.Blocked,
"OpenClaw Gateway is not connected. The run remains durable in Nexus and can be retried.");
}
if (!connector.Supports("chat.send"))
{
return new OpenClawRunGatewayResult(
false,
false,
OpenClawRunStates.Unsupported,
"The connected OpenClaw Gateway does not advertise chat.send.");
}
try
{
var response = await connector.InvokeAsync(
"chat.send",
new
{
sessionKey = run.SessionKey,
agentId = run.AgentId,
message = run.Prompt,
deliver = false,
idempotencyKey = invocation.IdempotencyKey
},
cancellationToken: cancellationToken,
invocationContext: ToGatewayContext(invocation, includeIdempotencyParameter: true));
var runId = ReadString(response?["runId"]);
var gatewayStatus = ReadString(response?["status"])?.ToLowerInvariant();
var state = gatewayStatus switch
{
"ok" => OpenClawRunStates.Completed,
"started" or "in_flight" => OpenClawRunStates.Running,
_ => OpenClawRunStates.Running
};
return new OpenClawRunGatewayResult(
true,
true,
state,
gatewayStatus is null
? "OpenClaw accepted the run."
: $"OpenClaw acknowledged the run as '{gatewayStatus}'.",
runId,
OpenClawPayloadSanitizer.Redact(response));
}
catch (OpenClawGatewayRpcException exception)
{
return FromException(exception);
}
}
public async Task<OpenClawRunGatewayResult> StopAsync(
OpenClawRun run,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
{
if (connector.ConnectionState != GatewayConnectionState.Connected)
{
return new OpenClawRunGatewayResult(
true,
false,
OpenClawRunStates.Blocked,
"OpenClaw Gateway is not connected; Nexus did not claim the run was stopped.");
}
if (string.IsNullOrWhiteSpace(run.OpenClawRunId))
{
return new OpenClawRunGatewayResult(
true,
false,
OpenClawRunStates.Blocked,
"The run has no exact OpenClaw run id. Nexus will not abort every run in the session.");
}
if (!connector.Supports("chat.abort")
&& !connector.Supports("sessions.abort"))
{
return new OpenClawRunGatewayResult(
false,
false,
OpenClawRunStates.Unsupported,
"The connected OpenClaw Gateway advertises neither chat.abort nor sessions.abort.");
}
var abortMethod = connector.Supports("chat.abort")
? "chat.abort"
: "sessions.abort";
var writeDecision = await writeGate.EvaluateAsync(
abortMethod,
"operator.write",
cancellationToken);
if (!writeDecision.Allowed)
{
return new OpenClawRunGatewayResult(
false,
false,
OpenClawRunStates.Blocked,
writeDecision.Recovery is null
? writeDecision.Message
: $"{writeDecision.Message} {writeDecision.Recovery}");
}
try
{
JsonNode? response;
if (connector.Supports("chat.abort"))
{
response = await connector.InvokeAsync(
"chat.abort",
new
{
sessionKey = run.SessionKey,
agentId = run.AgentId,
runId = run.OpenClawRunId
},
cancellationToken: cancellationToken,
invocationContext: ToGatewayContext(invocation));
}
else
{
response = await connector.InvokeAsync(
"sessions.abort",
new
{
key = run.SessionKey,
runId = run.OpenClawRunId,
clearQueued = false
},
cancellationToken: cancellationToken,
invocationContext: ToGatewayContext(invocation));
}
return new OpenClawRunGatewayResult(
true,
true,
OpenClawRunStates.Stopped,
"OpenClaw accepted the exact run abort.",
run.OpenClawRunId,
OpenClawPayloadSanitizer.Redact(response));
}
catch (OpenClawGatewayRpcException exception)
{
return FromException(exception);
}
}
public async Task<OpenClawRunGatewayResult> GetHistoryAsync(
OpenClawRun run,
int limit,
CancellationToken cancellationToken = default)
{
if (connector.ConnectionState != GatewayConnectionState.Connected)
{
return new OpenClawRunGatewayResult(
true,
false,
OpenClawRunStates.Blocked,
"OpenClaw Gateway is disconnected; durable Nexus transitions are still available.");
}
if (!connector.Supports("chat.history"))
{
return new OpenClawRunGatewayResult(
false,
false,
OpenClawRunStates.Unsupported,
"The connected OpenClaw Gateway does not advertise chat.history.");
}
try
{
var response = await connector.InvokeAsync(
"chat.history",
new
{
sessionKey = run.SessionKey,
agentId = run.AgentId,
limit = Math.Clamp(limit, 1, 1000),
maxChars = 200_000
},
cancellationToken: cancellationToken);
return new OpenClawRunGatewayResult(
true,
true,
"available",
"OpenClaw returned display-normalized session history.",
run.OpenClawRunId,
OpenClawPayloadSanitizer.Redact(response));
}
catch (OpenClawGatewayRpcException exception)
{
return FromException(exception);
}
}
private static OpenClawRunGatewayResult FromException(OpenClawGatewayRpcException exception)
{
var state = exception.Code is "GATEWAY_DISCONNECTED" or "UNAVAILABLE"
? OpenClawRunStates.Blocked
: OpenClawRunStates.Failed;
return new OpenClawRunGatewayResult(
true,
false,
state,
$"OpenClaw rejected the operation ({exception.Code}).");
}
private static OpenClawInvocationContext ToGatewayContext(
OpenClawInvocationMetadata invocation,
bool includeIdempotencyParameter = false)
=> OpenClawInvocationContext.Create(
invocation.Actor,
invocation.IdempotencyKey,
invocation.CorrelationId,
invocation.TraceParent,
includeIdempotencyParameter);
private static string? ReadString(JsonNode? value)
{
try
{
return value?.GetValue<string>();
}
catch
{
return value?.ToJsonString();
}
}
}