feat: complete Nexus mission-control workflows
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public static class AgentActivityText
|
||||
{
|
||||
private static readonly (Regex Pattern, string Replacement)[] InlineRedactions =
|
||||
[
|
||||
(new Regex(@"(?i)(authorization\s*:\s*bearer)\s+\S+", RegexOptions.CultureInvariant), "$1 [redacted]"),
|
||||
(new Regex(@"(?i)(x-nexus-api-key\s*:\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
|
||||
(new Regex(@"(?i)(api[_-]?key\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
|
||||
(new Regex(@"(?i)(token\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
|
||||
(new Regex(@"(?i)(password\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
|
||||
(new Regex(@"(?i)(secret\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
|
||||
(new Regex(@"(?i)(jwt\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
|
||||
(new Regex(@"(?i)(private[_-]?key\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]")
|
||||
];
|
||||
|
||||
private static readonly Regex[] ResidualSensitivePatterns =
|
||||
[
|
||||
new(@"(?i)bearer\s+(?!\[redacted\])\S+", RegexOptions.CultureInvariant),
|
||||
new(@"(?i)x-nexus-api-key\s*:\s*(?!\[redacted\])\S+", RegexOptions.CultureInvariant),
|
||||
new(@"(?i)private[_-]?key\s*[:=]\s*(?!\[redacted\])\S+", RegexOptions.CultureInvariant)
|
||||
];
|
||||
|
||||
private static readonly string[] KnownActorIds =
|
||||
[
|
||||
.. AgentIdentityCatalog.DefaultConfiguredAgentIds,
|
||||
"bao",
|
||||
"nexus-system"
|
||||
];
|
||||
|
||||
public static string RedactForDisplay(string? content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return content ?? string.Empty;
|
||||
|
||||
var lines = content.Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var sanitized = lines[i];
|
||||
foreach (var (pattern, replacement) in InlineRedactions)
|
||||
{
|
||||
sanitized = pattern.Replace(sanitized, replacement);
|
||||
}
|
||||
|
||||
if (ResidualSensitivePatterns.Any(pattern => pattern.IsMatch(sanitized)))
|
||||
sanitized = "[redacted sensitive line]";
|
||||
|
||||
lines[i] = sanitized;
|
||||
}
|
||||
|
||||
return string.Join('\n', lines).Trim();
|
||||
}
|
||||
|
||||
public static bool MatchesAgent(string? content, string agentId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agentId))
|
||||
return false;
|
||||
|
||||
var normalized = agentId.Trim().ToLowerInvariant();
|
||||
return ExtractAgentIds(content).Contains(normalized, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string[] ExtractAgentIds(string? content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return [];
|
||||
|
||||
var matches = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var actorId in KnownActorIds)
|
||||
{
|
||||
if (BuildActorRegex(actorId).IsMatch(content))
|
||||
matches.Add(actorId);
|
||||
}
|
||||
|
||||
return matches
|
||||
.Select(actorId => actorId.ToLowerInvariant())
|
||||
.OrderBy(actorId => actorId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static Regex BuildActorRegex(string actorId)
|
||||
=> ActorPatternCache.GetOrAdd(actorId, static key =>
|
||||
new Regex($@"(?<![a-z0-9]){Regex.Escape(key)}(?![a-z0-9])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant));
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Regex> ActorPatternCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Nexus.Api.Helpers;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
@@ -27,6 +28,8 @@ public sealed class AgentConfigService : IAgentConfigService
|
||||
{
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
return null;
|
||||
if (!AllowedFiles.Contains(fileName))
|
||||
return null;
|
||||
|
||||
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
|
||||
@@ -37,18 +40,44 @@ public sealed class AgentConfigService : IAgentConfigService
|
||||
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
|
||||
}
|
||||
|
||||
public async Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
|
||||
public async Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
|
||||
{
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
return null;
|
||||
var fileKind = DetermineFileKind(fileName);
|
||||
var validation = Validate(fileName, content, fileKind);
|
||||
var backup = new AgentConfigBackupResult("not_applicable", BackupCreated: false);
|
||||
var reload = CreateReloadCheck();
|
||||
if (validation.Errors.Count > 0)
|
||||
return new AgentConfigSaveAttempt(null, new AgentConfigSaveFailure("validation_failed", validation, backup, reload));
|
||||
|
||||
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||
if (!Directory.Exists(workspacePath))
|
||||
return new AgentConfigSaveAttempt(
|
||||
null,
|
||||
new AgentConfigSaveFailure(
|
||||
"workspace_not_found",
|
||||
new AgentConfigValidationResult("failed", fileKind, ["Agent workspace is not available on this node."]),
|
||||
backup,
|
||||
reload));
|
||||
|
||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
|
||||
return null;
|
||||
return new AgentConfigSaveAttempt(
|
||||
null,
|
||||
new AgentConfigSaveFailure(
|
||||
"invalid_path",
|
||||
new AgentConfigValidationResult("failed", fileKind, ["Invalid filename or path."]),
|
||||
backup,
|
||||
reload));
|
||||
|
||||
var tempPath = safePath + ".tmp";
|
||||
var backupPath = safePath + ".bak";
|
||||
var backupCreated = false;
|
||||
try
|
||||
{
|
||||
if (File.Exists(safePath))
|
||||
{
|
||||
File.Copy(safePath, backupPath, overwrite: true);
|
||||
backupCreated = true;
|
||||
}
|
||||
await File.WriteAllTextAsync(tempPath, content, ct);
|
||||
File.Move(tempPath, safePath!, overwrite: true);
|
||||
}
|
||||
@@ -59,6 +88,60 @@ public sealed class AgentConfigService : IAgentConfigService
|
||||
}
|
||||
|
||||
var fi = new FileInfo(safePath!);
|
||||
return new AgentConfigFileSaveResult(fileName, fi.Length, fi.LastWriteTimeUtc);
|
||||
return new AgentConfigSaveAttempt(
|
||||
new AgentConfigFileSaveResult(
|
||||
fileName,
|
||||
fi.Length,
|
||||
fi.LastWriteTimeUtc,
|
||||
new AgentConfigValidationResult("passed", fileKind, []),
|
||||
new AgentConfigBackupResult(backupCreated ? "created" : "not_applicable", backupCreated),
|
||||
CreateReloadCheck()),
|
||||
null);
|
||||
}
|
||||
|
||||
private static AgentConfigValidationResult Validate(string fileName, string content, string fileKind)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
errors.Add("Filename is invalid.");
|
||||
else if (!AllowedFiles.Contains(fileName))
|
||||
errors.Add("File is not allowed for Mission Control editing.");
|
||||
|
||||
if (content.IndexOf('\0') >= 0)
|
||||
errors.Add("Content contains null bytes.");
|
||||
|
||||
if (content.Length > IAgentConfigService.MaxConfigFileBytes)
|
||||
errors.Add($"Content exceeds maximum size of {IAgentConfigService.MaxConfigFileBytes / 1024}KB.");
|
||||
|
||||
if (string.Equals(fileKind, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
JsonDocument.Parse(content);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add($"JSON validation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new AgentConfigValidationResult(errors.Count == 0 ? "passed" : "failed", fileKind, errors);
|
||||
}
|
||||
|
||||
private static string DetermineFileKind(string fileName)
|
||||
{
|
||||
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
return "json";
|
||||
|
||||
if (fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
return "markdown";
|
||||
|
||||
return "text";
|
||||
}
|
||||
|
||||
private static AgentConfigReloadCheckResult CreateReloadCheck()
|
||||
=> new(
|
||||
"not_supported",
|
||||
"Mission Control verified the file write locally, but agent hot reload is not available for workspace config files.");
|
||||
}
|
||||
|
||||
@@ -112,6 +112,19 @@ public sealed class DashboardService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetGatewayInfoAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Gateway info fetch failed");
|
||||
return new GatewayRuntimeInfo(false, "unknown", null, null, false, false, "error", DateTimeOffset.UtcNow, "Gateway nicht erreichbar", "Gateway nicht erreichbar");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
|
||||
{
|
||||
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -4,11 +4,38 @@ public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime Mo
|
||||
|
||||
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
|
||||
|
||||
public sealed record AgentConfigFileSaveResult(string FileName, long Size, DateTime ModifiedAt);
|
||||
public sealed record AgentConfigValidationResult(string Status, string FileKind, IReadOnlyList<string> Errors);
|
||||
|
||||
public sealed record AgentConfigBackupResult(string Status, bool BackupCreated);
|
||||
|
||||
public sealed record AgentConfigReloadCheckResult(string Status, string Message);
|
||||
|
||||
public sealed record AgentConfigFileSaveResult(
|
||||
string FileName,
|
||||
long Size,
|
||||
DateTime ModifiedAt,
|
||||
AgentConfigValidationResult Validation,
|
||||
AgentConfigBackupResult Backup,
|
||||
AgentConfigReloadCheckResult ReloadCheck
|
||||
);
|
||||
|
||||
public sealed record AgentConfigSaveFailure(
|
||||
string Code,
|
||||
AgentConfigValidationResult Validation,
|
||||
AgentConfigBackupResult Backup,
|
||||
AgentConfigReloadCheckResult ReloadCheck
|
||||
);
|
||||
|
||||
public sealed record AgentConfigSaveAttempt(
|
||||
AgentConfigFileSaveResult? SaveResult,
|
||||
AgentConfigSaveFailure? Failure
|
||||
);
|
||||
|
||||
public interface IAgentConfigService
|
||||
{
|
||||
const int MaxConfigFileBytes = 500 * 1024;
|
||||
|
||||
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
|
||||
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
|
||||
Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
|
||||
Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public interface IDashboardService
|
||||
Task<ChatResponse> SendChatAsync(string agentId, string message);
|
||||
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
|
||||
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
|
||||
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct);
|
||||
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
|
||||
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
|
||||
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||
|
||||
@@ -12,6 +12,7 @@ public interface IOpenClawGatewayClient
|
||||
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
|
||||
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
|
||||
Task<List<QueueItem>> GetQueueAsync();
|
||||
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default);
|
||||
Task<bool> DeleteCronJobAsync(string id);
|
||||
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||
Task<bool> SetAgentModelAsync(string agentId, string model);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public interface IStaleTaskRecoveryService
|
||||
{
|
||||
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.ComponentModel;
|
||||
using System.Security.Claims;
|
||||
using ModelContextProtocol.Server;
|
||||
using Nexus.Api.Controllers;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class NexusMcpTools(
|
||||
ITaskBridgeService bridge,
|
||||
IAgentService agentService,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IConfiguration configuration,
|
||||
ILogger<NexusMcpTools> logger)
|
||||
{
|
||||
[McpServerTool(Name = "nexus_get_board")]
|
||||
[Description("Get the full Nexus task board grouped by canonical states.")]
|
||||
public async Task<BoardResponse> GetBoard(CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
return await bridge.GetBoardAsync(ct);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_agent_overview")]
|
||||
[Description("Get agent workflow overview, including waiting and stale task groups.")]
|
||||
public async Task<AgentWorkflowOverview> GetAgentOverview(
|
||||
[Description("Stale threshold in hours. Defaults to 2.")]
|
||||
int staleHours = 2,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
return await bridge.GetAgentOverviewAsync(TimeSpan.FromHours(Math.Max(1, staleHours)), ct);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_get_task")]
|
||||
[Description("Get one Nexus task by ID.")]
|
||||
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> GetTask(Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
return ToResponse(await bridge.GetTaskAsync(taskId, ct), "nexus_get_task");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_get_children")]
|
||||
[Description("Get child tasks for a Nexus parent task.")]
|
||||
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildren(Guid parentTaskId, CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
return await bridge.GetChildTasksAsync(parentTaskId, ct);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_get_activity")]
|
||||
[Description("Get activity entries for a Nexus task.")]
|
||||
public async Task<IReadOnlyList<ActivityEntryDto>> GetActivity(Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
var activity = await bridge.GetTaskActivityAsync(taskId, ct);
|
||||
return activity.Select(entry => new ActivityEntryDto(entry.Id, entry.Type, entry.Message, entry.CreatedAt)).ToList();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_create_task")]
|
||||
[Description("Create a top-level Nexus task.")]
|
||||
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateTask(
|
||||
string title,
|
||||
string? detail = null,
|
||||
string? priority = "Normal",
|
||||
string? assignedTo = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var caller = await ResolveCallerAsync(ct);
|
||||
var result = await bridge.CreateTaskAsync(
|
||||
title: title,
|
||||
detail: detail,
|
||||
source: ResolveSource(caller),
|
||||
priority: priority,
|
||||
assignedTo: assignedTo ?? caller,
|
||||
ct: ct);
|
||||
|
||||
return ToResponse(result, "nexus_create_task");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_create_child_task")]
|
||||
[Description("Create a visible child task under a Nexus parent task for delegation.")]
|
||||
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateChildTask(
|
||||
Guid parentTaskId,
|
||||
string title,
|
||||
string? detail = null,
|
||||
string? priority = "Normal",
|
||||
string? assignedTo = null,
|
||||
string? expectedFrom = null,
|
||||
bool startsInProgress = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var caller = await ResolveCallerAsync(ct);
|
||||
var result = await bridge.CreateChildTaskAsync(
|
||||
parentTaskId: parentTaskId,
|
||||
title: title,
|
||||
detail: detail,
|
||||
source: ResolveSource(caller),
|
||||
priority: priority,
|
||||
assignedTo: assignedTo,
|
||||
expectedFrom: expectedFrom ?? assignedTo,
|
||||
startsInProgress: startsInProgress,
|
||||
ct: ct);
|
||||
|
||||
return ToResponse(result, "nexus_create_child_task");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_update_status")]
|
||||
[Description("Update a Nexus task status. The schema only exposes canonical task states.")]
|
||||
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> UpdateStatus(
|
||||
Guid taskId,
|
||||
NexusMcpTaskState state,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var caller = await ResolveCallerAsync(ct);
|
||||
var result = await bridge.UpdateStatusAsync(taskId, ToStateString(state), caller, ct);
|
||||
return ToResponse(result, "nexus_update_status");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_append_activity")]
|
||||
[Description("Append an activity/checkpoint entry to a Nexus task.")]
|
||||
public async Task<TaskBridgeCommandResponse<ActivityEntryDto>> AppendActivity(
|
||||
Guid taskId,
|
||||
string message,
|
||||
string? type = "comment",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
var result = await bridge.AppendActivityAsync(taskId, message, type, ct);
|
||||
return ToActivityResponse(result, "nexus_append_activity");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_handoff")]
|
||||
[Description("Mark a task handoff to another known agent and append handoff activity.")]
|
||||
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> Handoff(
|
||||
Guid taskId,
|
||||
string targetAgent,
|
||||
string? note = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
var result = await bridge.HandoffAsync(taskId, targetAgent, note, ct);
|
||||
return ToResponse(result, "nexus_handoff");
|
||||
}
|
||||
|
||||
private async Task<string> ResolveCallerAsync(CancellationToken ct)
|
||||
{
|
||||
var context = httpContextAccessor.HttpContext
|
||||
?? throw new UnauthorizedAccessException("MCP request context is not available.");
|
||||
|
||||
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
|
||||
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
|
||||
|
||||
var agentHeader = context.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||
{
|
||||
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
|
||||
if (allowedActorIds.Contains(normalizedHeader))
|
||||
return normalizedHeader;
|
||||
|
||||
logger.LogWarning("MCP: ignoring unknown X-Agent-Id '{AgentId}' from {Ip}",
|
||||
normalizedHeader,
|
||||
context.Connection.RemoteIpAddress);
|
||||
}
|
||||
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var normalizedClaim = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim))
|
||||
return normalizedClaim;
|
||||
|
||||
if (context.User.IsInRole("owner") || context.User.IsInRole("admin"))
|
||||
return "bao";
|
||||
}
|
||||
|
||||
if (RequestAuthorizationHelper.IsAuthenticatedService(context, configuration) &&
|
||||
allowedActorIds.Contains("nexus-system"))
|
||||
return "nexus-system";
|
||||
|
||||
logger.LogWarning("MCP: unauthenticated request rejected from {Ip}", context.Connection.RemoteIpAddress);
|
||||
throw new UnauthorizedAccessException("MCP tools require X-Nexus-Api-Key or a recognized X-Agent-Id.");
|
||||
}
|
||||
|
||||
private static string ResolveSource(string agentId) => agentId switch
|
||||
{
|
||||
"bao" or "nexus-system" => "bao",
|
||||
_ => agentId
|
||||
};
|
||||
|
||||
private static string ToStateString(NexusMcpTaskState state) => state switch
|
||||
{
|
||||
NexusMcpTaskState.Backlog => TaskStateHelper.ToStateString(TaskState.Backlog),
|
||||
NexusMcpTaskState.InProgress => TaskStateHelper.ToStateString(TaskState.InProgress),
|
||||
NexusMcpTaskState.Blocked => TaskStateHelper.ToStateString(TaskState.Blocked),
|
||||
NexusMcpTaskState.Done => TaskStateHelper.ToStateString(TaskState.Done),
|
||||
NexusMcpTaskState.Review => TaskStateHelper.ToStateString(TaskState.Review),
|
||||
_ => throw new InvalidEnumArgumentException(nameof(state), (int)state, typeof(NexusMcpTaskState))
|
||||
};
|
||||
|
||||
private static TaskBridgeCommandResponse<T> ToResponse<T>(TaskBridgeResult<T> result, string command) where T : class
|
||||
=> new()
|
||||
{
|
||||
Ok = result.Outcome == TaskBridgeOutcome.Success,
|
||||
Command = command,
|
||||
Data = result.Outcome == TaskBridgeOutcome.Success ? result.Data : null,
|
||||
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
|
||||
};
|
||||
|
||||
private static TaskBridgeCommandResponse<ActivityEntryDto> ToActivityResponse(
|
||||
TaskBridgeResult<ActivityEvent> result,
|
||||
string command)
|
||||
=> new()
|
||||
{
|
||||
Ok = result.Outcome == TaskBridgeOutcome.Success,
|
||||
Command = command,
|
||||
Data = result.Data is null
|
||||
? null
|
||||
: new ActivityEntryDto(result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt),
|
||||
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
public enum NexusMcpTaskState
|
||||
{
|
||||
Backlog,
|
||||
InProgress,
|
||||
Blocked,
|
||||
Done,
|
||||
Review
|
||||
}
|
||||
@@ -8,6 +8,14 @@ namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient
|
||||
{
|
||||
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(15);
|
||||
|
||||
private static readonly string[] SensitiveMarkers =
|
||||
[
|
||||
"api_key", "apikey", "api-key", "authorization", "bearer ", "password",
|
||||
"token", "secret", "x-nexus-api-key", "jwt", "private_key"
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
@@ -139,6 +147,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
// 3. Extract activity from session_status
|
||||
var isActive = false;
|
||||
string? currentTask = null;
|
||||
var statusText = status?["status"]?.GetValue<string>();
|
||||
if (status is not null)
|
||||
{
|
||||
// Check explicit isActive field
|
||||
@@ -149,7 +158,6 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Fall back to status text
|
||||
var statusText = status["status"]?.GetValue<string>();
|
||||
if (!isActive && statusText is not null)
|
||||
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(statusText, "running", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -191,6 +199,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
// 8. Calculate workload from queue items
|
||||
var workload = CalculateAgentWorkload(id, queueItems);
|
||||
|
||||
var statusKind = DeriveStatusKind(status, isActive);
|
||||
var statusDetail = DeriveStatusDetail(status, statusKind);
|
||||
|
||||
agents.Add(new DashboardAgentInfo(
|
||||
Id: id,
|
||||
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
|
||||
@@ -204,7 +215,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
Workload: workload,
|
||||
Goal: goal,
|
||||
RoleBadge: DeriveRoleBadge(id),
|
||||
StatusLabel: DeriveStatusLabel(isActive, status),
|
||||
StatusLabel: DeriveStatusLabel(statusKind, isActive, statusText),
|
||||
StatusKind: statusKind,
|
||||
StatusDetail: statusDetail,
|
||||
Elapsed: FormatElapsed(status),
|
||||
Think: null,
|
||||
Next: DeriveNext(isActive, currentTask)
|
||||
@@ -692,6 +705,72 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default)
|
||||
{
|
||||
var baseUrl = httpClient.BaseAddress?.ToString().TrimEnd('/') ?? "unknown";
|
||||
var requiredVersion = NormalizeOptional(configuration["Integrations:OpenClaw:RequiredVersion"]);
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
ApplyAuth(request);
|
||||
using var response = await httpClient.SendAsync(request, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
string? version = response.Headers.TryGetValues("X-OpenClaw-Version", out var headerValues)
|
||||
? headerValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(version) && !string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
version = TryGetString(root, "version")
|
||||
?? TryGetString(root, "gatewayVersion")
|
||||
?? TryGetString(root, "openclawVersion");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Health endpoint may be plain text.
|
||||
}
|
||||
}
|
||||
|
||||
version = NormalizeOptional(version);
|
||||
var pinned = requiredVersion is not null;
|
||||
var versionStatus = DetermineVersionStatus(response.IsSuccessStatusCode, version, requiredVersion);
|
||||
var matches = versionStatus is "matched" or "unpinned";
|
||||
var message = BuildGatewayMessage(response.IsSuccessStatusCode, versionStatus, requiredVersion);
|
||||
var warning = BuildGatewayWarning(response.IsSuccessStatusCode, versionStatus, version, requiredVersion, null);
|
||||
|
||||
return new GatewayRuntimeInfo(
|
||||
response.IsSuccessStatusCode,
|
||||
baseUrl,
|
||||
version,
|
||||
requiredVersion,
|
||||
pinned,
|
||||
response.IsSuccessStatusCode && matches,
|
||||
versionStatus,
|
||||
DateTimeOffset.UtcNow,
|
||||
message,
|
||||
warning);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var warning = BuildGatewayWarning(false, "error", null, requiredVersion, "Gateway nicht erreichbar");
|
||||
return new GatewayRuntimeInfo(
|
||||
false,
|
||||
baseUrl,
|
||||
null,
|
||||
requiredVersion,
|
||||
requiredVersion is not null,
|
||||
false,
|
||||
"error",
|
||||
DateTimeOffset.UtcNow,
|
||||
"Gateway nicht erreichbar",
|
||||
warning);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteCronJobAsync(string id)
|
||||
{
|
||||
try
|
||||
@@ -980,13 +1059,14 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
continue;
|
||||
|
||||
// Truncate content to first 200 chars for compact display
|
||||
var text = msg.Content.Length > 200
|
||||
? msg.Content[..200] + "…"
|
||||
: msg.Content;
|
||||
var redacted = AgentActivityText.RedactForDisplay(msg.Content);
|
||||
var text = redacted.Length > 200
|
||||
? redacted[..200] + "…"
|
||||
: redacted;
|
||||
var ts = ParseTimestamp(msg.Timestamp);
|
||||
var timeAgo = FormatTimeAgo(ts);
|
||||
|
||||
entries.Add(new AgentActivityEntry(timeAgo, text));
|
||||
entries.Add(new AgentActivityEntry(timeAgo, text, ts));
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -1076,25 +1156,83 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
_ => "badge-slate"
|
||||
};
|
||||
|
||||
private static string DeriveStatusLabel(bool isActive, JsonNode? status)
|
||||
private static string DeriveStatusLabel(string statusKind, bool isActive, string? statusText)
|
||||
{
|
||||
if (!isActive) return "Bereit";
|
||||
var statusText = status?["status"]?.GetValue<string>()?.ToLowerInvariant();
|
||||
return statusText switch
|
||||
return statusKind switch
|
||||
{
|
||||
"thinking" or "think" => "Plant",
|
||||
"blocked" or "block" => "Blockiert",
|
||||
_ => "Arbeitet"
|
||||
"connected" => isActive ? "Arbeitet" : "Verbunden",
|
||||
"thinking" => "Plant",
|
||||
"blocked" => "Blockiert",
|
||||
"stale" => "Stale",
|
||||
"error" => "Fehler",
|
||||
"unsupported" => "Unsupported",
|
||||
"ready" => "Bereit",
|
||||
_ => statusText?.ToLowerInvariant() switch
|
||||
{
|
||||
"thinking" or "think" => "Plant",
|
||||
"blocked" or "block" => "Blockiert",
|
||||
_ => isActive ? "Arbeitet" : "Bereit"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string DeriveStatusKind(JsonNode? status, bool isActive)
|
||||
{
|
||||
if (status is null)
|
||||
return "error";
|
||||
|
||||
var statusText = status["status"]?.GetValue<string>()?.Trim();
|
||||
var errorText = status["error"]?.GetValue<string>()?.Trim()
|
||||
?? status["message"]?.GetValue<string>()?.Trim();
|
||||
var normalized = statusText?.ToLowerInvariant();
|
||||
var detail = $"{statusText} {errorText}".Trim().ToLowerInvariant();
|
||||
|
||||
if (detail.Contains("unsupported", StringComparison.Ordinal))
|
||||
return "unsupported";
|
||||
if (!string.IsNullOrWhiteSpace(errorText)
|
||||
|| normalized is "error" or "failed" or "offline" or "disconnected" or "unreachable")
|
||||
return "error";
|
||||
if (normalized is "blocked" or "block")
|
||||
return "blocked";
|
||||
if (normalized is "thinking" or "think")
|
||||
return "thinking";
|
||||
|
||||
var lastActivity = TryGetStatusTimestamp(status);
|
||||
if (lastActivity is not null && DateTimeOffset.UtcNow - lastActivity.Value > StaleThreshold)
|
||||
return "stale";
|
||||
|
||||
if (isActive || normalized is "active" or "running" or "connected" or "online")
|
||||
return "connected";
|
||||
|
||||
return "ready";
|
||||
}
|
||||
|
||||
private static string? DeriveStatusDetail(JsonNode? status, string statusKind)
|
||||
{
|
||||
if (status is null)
|
||||
return "Gateway-Status nicht abrufbar";
|
||||
|
||||
var message = NormalizeOptional(status["message"]?.GetValue<string>())
|
||||
?? NormalizeOptional(status["error"]?.GetValue<string>())
|
||||
?? NormalizeOptional(status["detail"]?.GetValue<string>());
|
||||
|
||||
if (message is not null)
|
||||
return message;
|
||||
|
||||
return statusKind switch
|
||||
{
|
||||
"stale" => FormatStaleDetail(TryGetStatusTimestamp(status)),
|
||||
"unsupported" => "Session meldet einen nicht unterstützten Zustand",
|
||||
"error" => "Session-Status konnte nicht gelesen werden",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FormatElapsed(JsonNode? status)
|
||||
{
|
||||
var lastActivity = status?["lastActivity"]?.GetValue<string>()
|
||||
?? status?["lastMessage"]?.GetValue<string>();
|
||||
var lastActivity = TryGetStatusTimestamp(status);
|
||||
if (lastActivity is null) return null;
|
||||
if (!DateTimeOffset.TryParse(lastActivity, out var ts)) return null;
|
||||
var diff = DateTimeOffset.UtcNow - ts;
|
||||
var diff = DateTimeOffset.UtcNow - lastActivity.Value;
|
||||
if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h";
|
||||
@@ -1120,4 +1258,96 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
"main" => "Assistant",
|
||||
_ => "Custom"
|
||||
};
|
||||
|
||||
private static string? TryGetString(JsonElement root, string property)
|
||||
=> root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty(property, out var value)
|
||||
&& value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
|
||||
public static string RedactSensitiveText(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return content;
|
||||
|
||||
var lines = content.Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var lower = lines[i].ToLowerInvariant();
|
||||
if (SensitiveMarkers.Any(marker => lower.Contains(marker, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
lines[i] = "[redacted sensitive line]";
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static DateTimeOffset? TryGetStatusTimestamp(JsonNode? status)
|
||||
{
|
||||
var raw = status?["lastActivity"]?.GetValue<string>()
|
||||
?? status?["lastMessage"]?.GetValue<string>()
|
||||
?? status?["updatedAt"]?.GetValue<string>();
|
||||
return DateTimeOffset.TryParse(raw, out var ts) ? ts : null;
|
||||
}
|
||||
|
||||
private static string DetermineVersionStatus(bool reachable, string? version, string? requiredVersion)
|
||||
{
|
||||
if (!reachable)
|
||||
return "error";
|
||||
if (requiredVersion is null)
|
||||
return version is null ? "unknown" : "unpinned";
|
||||
if (version is null)
|
||||
return "missing";
|
||||
return string.Equals(version, requiredVersion, StringComparison.OrdinalIgnoreCase) ? "matched" : "drift";
|
||||
}
|
||||
|
||||
private static string BuildGatewayMessage(bool reachable, string versionStatus, string? requiredVersion)
|
||||
{
|
||||
if (!reachable)
|
||||
return "Gateway nicht erreichbar";
|
||||
|
||||
return versionStatus switch
|
||||
{
|
||||
"matched" => "Gateway erreichbar und Version gepinnt",
|
||||
"missing" => requiredVersion is null
|
||||
? "Gateway erreichbar"
|
||||
: $"Gateway erreichbar, aber Versionspin {requiredVersion} nicht nachweisbar",
|
||||
"drift" => "Gateway erreichbar, aber Version weicht vom Pin ab",
|
||||
"unpinned" => "Gateway erreichbar",
|
||||
"unknown" => "Gateway erreichbar, Version nicht erkannt",
|
||||
_ => "Gateway erreichbar"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? BuildGatewayWarning(bool reachable, string versionStatus, string? version, string? requiredVersion, string? fallback)
|
||||
{
|
||||
if (!reachable)
|
||||
return fallback ?? "Gateway nicht erreichbar";
|
||||
|
||||
return versionStatus switch
|
||||
{
|
||||
"missing" when requiredVersion is not null => $"Gateway meldet keine Version; erwartet wird {requiredVersion}.",
|
||||
"drift" when requiredVersion is not null => $"Gateway meldet {version ?? "unknown"} statt {requiredVersion}.",
|
||||
"unknown" => "Gateway-Version konnte nicht erkannt werden.",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FormatStaleDetail(DateTimeOffset? lastActivity)
|
||||
{
|
||||
if (lastActivity is null)
|
||||
return "Letzte Aktivität ist veraltet";
|
||||
|
||||
var diff = DateTimeOffset.UtcNow - lastActivity.Value;
|
||||
if (diff.TotalMinutes < 60)
|
||||
return $"Keine neue Aktivität seit {(int)diff.TotalMinutes}m";
|
||||
if (diff.TotalHours < 24)
|
||||
return $"Keine neue Aktivität seit {(int)diff.TotalHours}h";
|
||||
return $"Keine neue Aktivität seit {(int)diff.TotalDays}d";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class StaleTaskRecoveryBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptionsMonitor<StaleTaskRecoveryOptions> optionsMonitor,
|
||||
ILogger<StaleTaskRecoveryBackgroundService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resetCount = await RunRecoveryOnceAsync(stoppingToken);
|
||||
if (resetCount > 0)
|
||||
logger.LogInformation("Stale task recovery reset {ResetCount} task(s).", resetCount);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Stale task recovery run failed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(optionsMonitor.CurrentValue.GetInterval(), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> RunRecoveryOnceAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>();
|
||||
return await recoveryService.ResetStaleInProgressTasksAsync(optionsMonitor.CurrentValue.GetStaleThreshold(), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class StaleTaskRecoveryOptions
|
||||
{
|
||||
public const string SectionName = "TaskRecovery";
|
||||
|
||||
public int StaleHours { get; set; } = 2;
|
||||
public int IntervalMinutes { get; set; } = 30;
|
||||
|
||||
public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours));
|
||||
|
||||
public TimeSpan GetInterval() => TimeSpan.FromMinutes(Math.Max(1, IntervalMinutes));
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class StaleTaskRecoveryService(
|
||||
ITaskRepository taskRepository,
|
||||
IActivityRepository activityRepository,
|
||||
ILiveUpdateService liveUpdateService) : IStaleTaskRecoveryService
|
||||
{
|
||||
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
{
|
||||
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||
var staleTasks = await GetStaleTasksAsync(threshold, ct);
|
||||
if (staleTasks.Count == 0)
|
||||
return 0;
|
||||
|
||||
var latestActivityByTaskId = await GetLatestActivityByTaskIdAsync(staleTasks.Select(task => task.Id), ct);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var resetCount = 0;
|
||||
|
||||
foreach (var task in staleTasks)
|
||||
{
|
||||
var currentTask = await taskRepository.GetByIdAsync(task.Id, ct);
|
||||
if (currentTask is null || !IsStaleInProgress(currentTask, threshold))
|
||||
continue;
|
||||
|
||||
latestActivityByTaskId.TryGetValue(currentTask.Id, out var lastActivityAt);
|
||||
var message = BuildActivityMessage(currentTask, staleThreshold, now, lastActivityAt);
|
||||
var updated = await taskRepository.TryResetStaleInProgressToBacklogAsync(
|
||||
currentTask.Id,
|
||||
threshold,
|
||||
now,
|
||||
ct);
|
||||
if (!updated)
|
||||
continue;
|
||||
|
||||
await activityRepository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "task",
|
||||
Message = message,
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
|
||||
resetCount++;
|
||||
}
|
||||
|
||||
if (resetCount > 0)
|
||||
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
|
||||
|
||||
return resetCount;
|
||||
}
|
||||
|
||||
private async Task<List<WorkTask>> GetStaleTasksAsync(DateTimeOffset threshold, CancellationToken ct)
|
||||
{
|
||||
var allTasks = await taskRepository.GetAllAsync(ct);
|
||||
|
||||
return allTasks
|
||||
.Where(task => IsStaleInProgress(task, threshold))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool IsStaleInProgress(WorkTask task, DateTimeOffset threshold)
|
||||
=> string.Equals(task.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase)
|
||||
&& task.UpdatedAt < threshold;
|
||||
|
||||
private async Task<Dictionary<Guid, DateTimeOffset>> GetLatestActivityByTaskIdAsync(
|
||||
IEnumerable<Guid> taskIds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var activities = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
|
||||
|
||||
return activities
|
||||
.Where(activity => activity.TaskId.HasValue)
|
||||
.GroupBy(activity => activity.TaskId!.Value)
|
||||
.ToDictionary(group => group.Key, group => group.Max(activity => activity.CreatedAt));
|
||||
}
|
||||
|
||||
private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct)
|
||||
{
|
||||
var allTasks = await taskRepository.GetAllAsync(ct);
|
||||
var taskIds = allTasks.Select(task => task.Id).ToList();
|
||||
var activity = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
|
||||
|
||||
var backlog = new List<DashboardTaskDto>();
|
||||
var inProgress = new List<DashboardTaskDto>();
|
||||
var review = new List<DashboardTaskDto>();
|
||||
var blocked = new List<DashboardTaskDto>();
|
||||
var done = new List<DashboardTaskDto>();
|
||||
|
||||
foreach (var task in allTasks)
|
||||
{
|
||||
var dto = MapToDtoWithChildren(task, allTasks, activity);
|
||||
switch (task.State.ToLowerInvariant())
|
||||
{
|
||||
case "backlog": backlog.Add(dto); break;
|
||||
case "in progress": inProgress.Add(dto); break;
|
||||
case "review": review.Add(dto); break;
|
||||
case "blocked": blocked.Add(dto); break;
|
||||
case "done": done.Add(dto); break;
|
||||
default: backlog.Add(dto); break;
|
||||
}
|
||||
}
|
||||
|
||||
backlog.Sort(SortByPriorityThenCreatedAt);
|
||||
inProgress.Sort(SortByPriorityThenCreatedAt);
|
||||
review.Sort(SortByPriorityThenCreatedAt);
|
||||
blocked.Sort(SortByPriorityThenCreatedAt);
|
||||
done.Sort(SortByPriorityThenCreatedAt);
|
||||
|
||||
return new BoardResponse(backlog, inProgress, review, blocked, done);
|
||||
}
|
||||
|
||||
private static DashboardTaskDto MapToDtoWithChildren(
|
||||
WorkTask task,
|
||||
IReadOnlyList<WorkTask> allTasks,
|
||||
IEnumerable<ActivityEvent> activity)
|
||||
{
|
||||
var childTasks = allTasks
|
||||
.Where(candidate => candidate.ParentTaskId == task.Id)
|
||||
.OrderByDescending(candidate => candidate.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity)).ToList();
|
||||
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
|
||||
var dto = MapToDtoWithActivity(task, activity);
|
||||
|
||||
return dto with
|
||||
{
|
||||
ChildTasks = childDtos,
|
||||
ChildTaskCount = childDtos.Count,
|
||||
OpenChildTaskCount = openChildTaskCount,
|
||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
|
||||
};
|
||||
}
|
||||
|
||||
private static DashboardTaskDto MapToDtoWithActivity(WorkTask task, IEnumerable<ActivityEvent> activity)
|
||||
{
|
||||
var last = activity
|
||||
.Where(entry => entry.TaskId == task.Id)
|
||||
.OrderByDescending(entry => entry.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new DashboardTaskDto(
|
||||
task.Id,
|
||||
task.Title,
|
||||
task.Detail,
|
||||
task.Source,
|
||||
task.State,
|
||||
task.Priority,
|
||||
task.AssignedTo,
|
||||
task.ParentTaskId,
|
||||
task.DueDate,
|
||||
task.CreatedAt,
|
||||
task.UpdatedAt,
|
||||
task.IsAgentTask,
|
||||
task.ExpectedFrom,
|
||||
last?.Message,
|
||||
last?.CreatedAt,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
task.ParentTaskId.HasValue || task.IsAgentTask);
|
||||
}
|
||||
|
||||
private static string BuildActivityMessage(
|
||||
WorkTask task,
|
||||
TimeSpan staleThreshold,
|
||||
DateTimeOffset now,
|
||||
DateTimeOffset? lastActivityAt)
|
||||
{
|
||||
var staleAge = now - task.UpdatedAt;
|
||||
var details = new List<string>
|
||||
{
|
||||
"reason=stale-recovery",
|
||||
"previous status In progress",
|
||||
$"stale reference {now:O}",
|
||||
$"stale age {FormatDuration(staleAge)}",
|
||||
$"threshold {FormatDuration(staleThreshold)}"
|
||||
};
|
||||
|
||||
if (lastActivityAt.HasValue)
|
||||
details.Add($"last activity {lastActivityAt.Value:O}");
|
||||
|
||||
details.Add($"last update {task.UpdatedAt:O}");
|
||||
details.Add("new status Backlog");
|
||||
|
||||
return $"Task \"{task.Title}\" reset from In progress to Backlog by stale recovery ({string.Join("; ", details)})";
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan duration)
|
||||
=> duration.ToString(@"dd\.hh\:mm\:ss");
|
||||
|
||||
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
|
||||
{
|
||||
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
|
||||
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt);
|
||||
}
|
||||
|
||||
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
|
||||
{
|
||||
"high" => 3,
|
||||
"medium" => 2,
|
||||
"normal" => 2,
|
||||
"low" => 1,
|
||||
_ => 2
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,8 @@ public sealed class TaskService(
|
||||
INotificationService notificationService,
|
||||
IAgentService agentService,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILiveUpdateService liveUpdateService) : ITaskService
|
||||
ILiveUpdateService liveUpdateService,
|
||||
IStaleTaskRecoveryService staleTaskRecoveryService) : ITaskService
|
||||
{
|
||||
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||
=> await taskRepo.GetAllAsync(ct);
|
||||
@@ -495,30 +496,8 @@ public sealed class TaskService(
|
||||
return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct);
|
||||
}
|
||||
|
||||
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
{
|
||||
var all = await taskRepo.GetAllAsync(ct);
|
||||
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||
var staleTasks = all.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold).ToList();
|
||||
|
||||
foreach (var task in staleTasks)
|
||||
{
|
||||
var prevState = task.State;
|
||||
task.State = "Backlog";
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "task",
|
||||
Message = $"Task \"{task.Title}\" reset from {prevState} to Backlog (stale)",
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
}
|
||||
|
||||
if (staleTasks.Count > 0)
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
|
||||
return staleTasks.Count;
|
||||
}
|
||||
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
=> staleTaskRecoveryService.ResetStaleInProgressTasksAsync(staleThreshold, ct);
|
||||
|
||||
public async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user