using System.Text.Json.Nodes; using Nexus.Api.Data; using Nexus.Api.Models; using Nexus.Api.Repositories; namespace Nexus.Api.Services; public sealed class OpenClawRunService( IOpenClawRunRepository runs, IOpenClawRunGateway gateway) : IOpenClawRunService { private const string ResumeCapabilityMessage = "OpenClaw exposes chat.send/chat.abort/history, but no durable same-run resume RPC. " + "Use Retry to create a correlated new run or start a follow-up turn."; public async Task GetAsync( OpenClawRunQuery query, CancellationToken cancellationToken = default) { var limit = Math.Clamp(query.Limit, 1, 200); var items = await runs.GetAsync(query with { Limit = limit + 1 }, cancellationToken); var hasMore = items.Count > limit; var selected = items.Take(limit).ToList(); var nextCursor = hasMore && selected.Count > 0 ? OpenClawRunCursorCodec.Encode( selected[^1].CreatedAt, selected[^1].Id) : null; return new OpenClawRunCollectionDto( selected.Select(Map).ToList(), nextCursor, DateTimeOffset.UtcNow); } public async Task GetByIdAsync( Guid id, CancellationToken cancellationToken = default) { var run = await runs.GetByIdAsync(id, cancellationToken: cancellationToken); return run is null ? null : Map(run); } public async Task StartAsync( StartOpenClawRunRequest request, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) { var existing = await runs.GetByStartIdempotencyKeyAsync( invocation.IdempotencyKey, cancellationToken); if (existing is not null) { var sameCommand = string.Equals(existing.Prompt, request.Prompt.Trim(), StringComparison.Ordinal) && string.Equals(existing.AgentId, request.AgentId.Trim(), StringComparison.Ordinal) && string.Equals(existing.SessionKey, request.SessionKey.Trim(), StringComparison.Ordinal); if (sameCommand && existing.Status == OpenClawRunStates.Dispatching && string.IsNullOrWhiteSpace(existing.OpenClawRunId)) { var recoverable = await runs.GetByIdAsync( existing.Id, tracking: true, cancellationToken: cancellationToken); if (recoverable is not null) { var recovered = await gateway.StartAsync(recoverable, invocation, cancellationToken); ApplyDispatchResult(recoverable, recovered); await runs.UpdateAsync( recoverable, History( recoverable, "start_recovery", OpenClawRunStates.Dispatching, recoverable.Status, $"Idempotent dispatch recovery: {recovered.Message}", invocation, idempotencyKey: null), cancellationToken); return Operation( recovered.Ok, recovered.State, recovered.Message, recoverable); } } return Operation( sameCommand, sameCommand ? "idempotent_replay" : "idempotency_conflict", sameCommand ? "The original start result was returned; OpenClaw was not invoked again." : "The idempotency key is already bound to a different run command.", existing); } await ValidateCorrelationsAsync(request.TaskId, request.ProjectId, cancellationToken); var now = DateTimeOffset.UtcNow; var run = new OpenClawRun { Title = BuildTitle(request.Title, request.Prompt), Prompt = request.Prompt.Trim(), AgentId = request.AgentId.Trim(), SessionKey = request.SessionKey.Trim(), TaskId = request.TaskId, ProjectId = request.ProjectId, Status = OpenClawRunStates.Dispatching, StartIdempotencyKey = invocation.IdempotencyKey, CorrelationId = invocation.CorrelationId, Actor = invocation.Actor, TraceParent = invocation.TraceParent, CreatedAt = now, UpdatedAt = now }; await runs.AddAsync( run, History( run, "start_requested", OpenClawRunStates.Dispatching, OpenClawRunStates.Dispatching, "Nexus durably recorded the run before dispatch.", invocation), cancellationToken); var result = await gateway.StartAsync(run, invocation, cancellationToken); ApplyDispatchResult(run, result); await runs.UpdateAsync( run, History( run, "start_result", OpenClawRunStates.Dispatching, run.Status, result.Message, invocation, idempotencyKey: null), cancellationToken); return Operation(result.Ok, result.State, result.Message, run); } public async Task StopAsync( Guid id, string? reason, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) { var run = await runs.GetByIdAsync(id, tracking: true, cancellationToken: cancellationToken); if (run is null) return null; var replay = await runs.GetInvocationAsync( id, "stop_requested", invocation.IdempotencyKey, cancellationToken); if (replay is not null) { return Operation( true, "idempotent_replay", "The original stop result was returned; OpenClaw was not invoked again.", run); } if (OpenClawRunStates.IsTerminal(run.Status)) { var message = $"Run is already terminal with state '{run.Status}'."; await runs.UpdateAsync( run, History( run, "stop_requested", run.Status, run.Status, message, invocation), cancellationToken); return Operation(true, "already_terminal", message, run); } var previousState = run.Status; run.Status = OpenClawRunStates.Stopping; await runs.UpdateAsync( run, History( run, "stop_requested", previousState, OpenClawRunStates.Stopping, BuildReasonMessage("Stop requested.", reason), invocation), cancellationToken); var result = await gateway.StopAsync(run, invocation, cancellationToken); run.Status = result.Ok ? OpenClawRunStates.Stopped : previousState; run.LastError = result.Ok ? null : result.Message; if (result.Ok) run.FinishedAt = DateTimeOffset.UtcNow; await runs.UpdateAsync( run, History( run, "stop_result", OpenClawRunStates.Stopping, run.Status, result.Message, invocation, idempotencyKey: null), cancellationToken); return Operation(result.Ok, result.State, result.Message, run); } public async Task ResumeAsync( Guid id, string? reason, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) { var run = await runs.GetByIdAsync(id, tracking: true, cancellationToken: cancellationToken); if (run is null) return null; var replay = await runs.GetInvocationAsync( id, "resume_requested", invocation.IdempotencyKey, cancellationToken); if (replay is not null) { return Operation( false, OpenClawRunStates.Unsupported, ResumeCapabilityMessage, run); } await runs.UpdateAsync( run, History( run, "resume_requested", run.Status, run.Status, BuildReasonMessage(ResumeCapabilityMessage, reason), invocation), cancellationToken); return Operation( false, OpenClawRunStates.Unsupported, ResumeCapabilityMessage, run); } public async Task RetryAsync( Guid id, string? reason, OpenClawInvocationMetadata invocation, CancellationToken cancellationToken = default) { var source = await runs.GetByIdAsync(id, tracking: true, cancellationToken: cancellationToken); if (source is null) return null; var existing = await runs.GetByStartIdempotencyKeyAsync( invocation.IdempotencyKey, cancellationToken); if (existing is not null) { var sameSource = existing.RetriedFromRunId == id; if (sameSource && existing.Status == OpenClawRunStates.Dispatching && string.IsNullOrWhiteSpace(existing.OpenClawRunId)) { var recoverable = await runs.GetByIdAsync( existing.Id, tracking: true, cancellationToken: cancellationToken); if (recoverable is not null) { var recovered = await gateway.StartAsync(recoverable, invocation, cancellationToken); ApplyDispatchResult(recoverable, recovered); await runs.UpdateAsync( recoverable, History( recoverable, "start_recovery", OpenClawRunStates.Dispatching, recoverable.Status, $"Idempotent retry dispatch recovery: {recovered.Message}", invocation, idempotencyKey: null), cancellationToken); return Operation( recovered.Ok, recovered.State, recovered.Message, source, recoverable); } } return Operation( sameSource, sameSource ? "idempotent_replay" : "idempotency_conflict", sameSource ? "The original retry result was returned; OpenClaw was not invoked again." : "The idempotency key is already bound to another run.", source, existing); } var rejectedReplay = await runs.GetInvocationAsync( id, "retry_rejected", invocation.IdempotencyKey, cancellationToken); if (rejectedReplay is not null) { return Operation( false, "invalid_state", "Only a terminal, blocked, or unsupported run can be retried.", source); } if (!OpenClawRunStates.IsTerminal(source.Status)) { const string message = "Only a terminal, blocked, or unsupported run can be retried."; await runs.UpdateAsync( source, History( source, "retry_rejected", source.Status, source.Status, BuildReasonMessage(message, reason), invocation), cancellationToken); return Operation(false, "invalid_state", message, source); } var now = DateTimeOffset.UtcNow; var retry = new OpenClawRun { Title = source.Title, Prompt = source.Prompt, AgentId = source.AgentId, SessionKey = source.SessionKey, TaskId = source.TaskId, ProjectId = source.ProjectId, RetriedFromRunId = source.Id, Status = OpenClawRunStates.Dispatching, StartIdempotencyKey = invocation.IdempotencyKey, CorrelationId = invocation.CorrelationId, Actor = invocation.Actor, TraceParent = invocation.TraceParent, CreatedAt = now, UpdatedAt = now }; await runs.AddRetryAsync( source, retry, History( source, "retry_requested", source.Status, source.Status, BuildReasonMessage($"Retry created run {retry.Id}.", reason), invocation, resultRunId: retry.Id), History( retry, "start_requested", OpenClawRunStates.Dispatching, OpenClawRunStates.Dispatching, $"Retry of Nexus run {source.Id} was durably recorded before dispatch.", invocation), cancellationToken); var result = await gateway.StartAsync(retry, invocation, cancellationToken); ApplyDispatchResult(retry, result); await runs.UpdateAsync( retry, History( retry, "start_result", OpenClawRunStates.Dispatching, retry.Status, result.Message, invocation, idempotencyKey: null), cancellationToken); return Operation(result.Ok, result.State, result.Message, source, retry); } public async Task GetHistoryAsync( Guid id, int gatewayLimit = 200, CancellationToken cancellationToken = default) { var run = await runs.GetByIdAsync(id, cancellationToken: cancellationToken); if (run is null) return null; var transitions = await runs.GetHistoryAsync(id, cancellationToken); var gatewayHistory = await gateway.GetHistoryAsync( run, Math.Clamp(gatewayLimit, 1, 1000), cancellationToken); return new OpenClawRunHistoryResponse( Map(run), transitions.Select(Map).ToList(), gatewayHistory.State, gatewayHistory.Data, gatewayHistory.Message, DateTimeOffset.UtcNow); } public async Task ReconcileAsync( GatewayEventEnvelope gatewayEvent, CancellationToken cancellationToken = default) { if (!IsRunEvent(gatewayEvent.Event)) return; var runId = ReadString(gatewayEvent.Payload?["runId"]); var state = ReadString(gatewayEvent.Payload?["state"])?.ToLowerInvariant(); if (string.IsNullOrWhiteSpace(runId) || string.IsNullOrWhiteSpace(state)) return; var projectedState = MapGatewayState(state); if (projectedState is null) return; var gatewayEventId = OpenClawEventIdentity.Create(gatewayEvent); if (await runs.HasGatewayEventAsync(gatewayEventId, cancellationToken)) return; var run = await runs.GetByOpenClawRunIdAsync(runId, cancellationToken); if (run is null) return; var sequence = ReadLong(gatewayEvent.Payload?["seq"]); if (sequence.HasValue && run.LastGatewaySequence.HasValue && sequence.Value <= run.LastGatewaySequence.Value) { return; } var previousStatus = run.Status; var sequenceGap = sequence.HasValue && run.LastGatewaySequence.HasValue && sequence.Value > run.LastGatewaySequence.Value + 1; if (!OpenClawRunStates.IsTerminal(run.Status) || OpenClawRunStates.IsTerminal(projectedState)) run.Status = projectedState; if (sequence.HasValue) run.LastGatewaySequence = sequence; run.SequenceGapDetected |= sequenceGap; if (run.StartedAt is null && projectedState == OpenClawRunStates.Running) run.StartedAt = gatewayEvent.ReceivedAt; if (OpenClawRunStates.IsTerminal(projectedState)) run.FinishedAt = gatewayEvent.ReceivedAt; if (projectedState == OpenClawRunStates.Failed) run.LastError = ReadString(gatewayEvent.Payload?["errorMessage"]) ?? "OpenClaw run failed."; else if (projectedState is OpenClawRunStates.Completed or OpenClawRunStates.Stopped) run.LastError = null; var shouldAudit = !string.Equals(previousStatus, run.Status, StringComparison.Ordinal) || sequenceGap || OpenClawRunStates.IsTerminal(projectedState); OpenClawRunHistory? history = null; if (shouldAudit) { history = new OpenClawRunHistory { RunId = run.Id, Action = "gateway_event", FromStatus = previousStatus, ToStatus = run.Status, Message = sequenceGap ? $"Gateway event '{gatewayEvent.Event}' reported a per-run sequence gap." : $"Gateway event '{gatewayEvent.Event}' projected run state '{state}'.", Actor = "openclaw-gateway", CorrelationId = run.CorrelationId, TraceParent = run.TraceParent, GatewayEventId = gatewayEventId, GatewaySequence = sequence, SequenceGapDetected = sequenceGap, OccurredAt = gatewayEvent.ReceivedAt }; } await runs.UpdateProjectionAsync(run, history, cancellationToken); } private async Task ValidateCorrelationsAsync( Guid? taskId, Guid? projectId, CancellationToken cancellationToken) { if (taskId.HasValue && !await runs.TaskExistsAsync(taskId.Value, cancellationToken)) throw new OpenClawRunValidationException("taskId", "The correlated Nexus task does not exist."); if (projectId.HasValue && !await runs.ProjectExistsAsync(projectId.Value, cancellationToken)) throw new OpenClawRunValidationException("projectId", "The correlated Nexus project does not exist."); } private static void ApplyDispatchResult(OpenClawRun run, OpenClawRunGatewayResult result) { run.Status = result.State; run.OpenClawRunId = result.OpenClawRunId ?? run.OpenClawRunId; run.LastError = result.Ok ? null : result.Message; if (result.Ok && run.StartedAt is null) run.StartedAt = DateTimeOffset.UtcNow; if (result.Ok && result.State == OpenClawRunStates.Completed) run.FinishedAt = DateTimeOffset.UtcNow; } private static OpenClawRunHistory History( OpenClawRun run, string action, string fromStatus, string toStatus, string message, OpenClawInvocationMetadata invocation, string? idempotencyKey = "__invocation__", Guid? resultRunId = null) => new() { RunId = run.Id, Action = action, FromStatus = fromStatus, ToStatus = toStatus, Message = message, Actor = invocation.Actor, CorrelationId = invocation.CorrelationId, IdempotencyKey = idempotencyKey == "__invocation__" ? invocation.IdempotencyKey : idempotencyKey, TraceParent = invocation.TraceParent, ResultRunId = resultRunId }; private static OpenClawRunOperationDto Operation( bool ok, string state, string message, OpenClawRun run, OpenClawRun? resultRun = null) => new( ok, state, message, Map(run), resultRun is null ? null : Map(resultRun), DateTimeOffset.UtcNow, new OperationResultDto( run.CorrelationId, state, run.Revision, new EntityRefDto("run", run.Id.ToString(), run.Title), resultRun is null ? [] : [new EntityRefDto( "run", resultRun.Id.ToString(), resultRun.Title)], run.TraceParent)); private static OpenClawRunDto Map(OpenClawRun run) => new( run.Id, run.Title, run.Prompt, run.AgentId, run.SessionKey, run.Status, run.TaskId, run.ProjectId, run.OpenClawRunId, run.RetriedFromRunId, run.CorrelationId, run.Actor, run.LastError, run.LastGatewaySequence, run.SequenceGapDetected, run.Status is OpenClawRunStates.Dispatching or OpenClawRunStates.Running or OpenClawRunStates.Stopping, OpenClawRunStates.IsTerminal(run.Status), false, ResumeCapabilityMessage, run.CreatedAt, run.UpdatedAt, run.StartedAt, run.FinishedAt); private static OpenClawRunHistoryDto Map(OpenClawRunHistory item) => new( item.Id, item.RunId, item.Action, item.FromStatus, item.ToStatus, item.Message, item.Actor, item.CorrelationId, item.IdempotencyKey, item.TraceParent, item.GatewayEventId, item.GatewaySequence, item.SequenceGapDetected, item.ResultRunId, item.OccurredAt); private static string BuildTitle(string? requestedTitle, string prompt) { var title = string.IsNullOrWhiteSpace(requestedTitle) ? prompt.Replace("\r", " ", StringComparison.Ordinal) .Replace("\n", " ", StringComparison.Ordinal) .Trim() : requestedTitle.Trim(); return title.Length <= 160 ? title : title[..157] + "..."; } private static string BuildReasonMessage(string message, string? reason) => string.IsNullOrWhiteSpace(reason) ? message : $"{message} Reason: {reason.Trim()}"; private static bool IsRunEvent(string eventName) => eventName.Equals("chat", StringComparison.OrdinalIgnoreCase) || eventName.Equals("agent", StringComparison.OrdinalIgnoreCase) || eventName.Equals("session.operation", StringComparison.OrdinalIgnoreCase); private static string? MapGatewayState(string state) => state switch { "status" or "delta" or "started" or "running" => OpenClawRunStates.Running, "final" or "completed" or "succeeded" => OpenClawRunStates.Completed, "aborted" or "cancelled" => OpenClawRunStates.Stopped, "error" or "failed" => OpenClawRunStates.Failed, _ => null }; private static string? ReadString(JsonNode? node) { try { return node?.GetValue(); } catch { return null; } } private static long? ReadLong(JsonNode? node) { try { if (node is null) return null; return node.GetValueKind() switch { System.Text.Json.JsonValueKind.Number => node.GetValue(), System.Text.Json.JsonValueKind.String when long.TryParse(node.GetValue(), out var value) => value, _ => null }; } catch { return null; } } } public sealed class OpenClawRunValidationException(string field, string message) : Exception(message) { public string Field { get; } = field; }