272 lines
9.0 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|