feat: complete Nexus mission-control workflows
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using System.Security.Claims;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Repositories;
|
||||
@@ -14,6 +16,7 @@ public class AgentsController(
|
||||
IAgentRuntime runtime,
|
||||
IActivityRepository activityRepo,
|
||||
IAgentConfigService agentConfigService,
|
||||
IDashboardService dashboardService,
|
||||
ILogger<AgentsController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
@@ -39,7 +42,25 @@ public class AgentsController(
|
||||
public async Task<IResult> GetAgentActivity(string id, CancellationToken ct)
|
||||
{
|
||||
var items = await activityRepo.GetByAgentAsync(id, 50, ct);
|
||||
return Results.Ok(items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }));
|
||||
var activity = items
|
||||
.Select(x => new AgentActivityResponse(x.Id, x.Type, x.Message, x.CreatedAt, "activity"))
|
||||
.ToList();
|
||||
|
||||
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 10);
|
||||
foreach (var entry in gatewayEntries)
|
||||
activity.Add(new AgentActivityResponse(null, "thinking", entry.Text, entry.Timestamp, entry.Source, entry.Time));
|
||||
|
||||
return Results.Ok(activity
|
||||
.OrderByDescending(x => x.At)
|
||||
.Take(50));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/summary")]
|
||||
public async Task<IResult> GetAgentSummary(string id, CancellationToken ct)
|
||||
{
|
||||
var recent = await activityRepo.GetByAgentAsync(id, 25, ct);
|
||||
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 8);
|
||||
return Results.Ok(AgentSummaryBuilder.Build(recent, gatewayEntries, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/command")]
|
||||
@@ -84,20 +105,48 @@ public class AgentsController(
|
||||
}
|
||||
|
||||
[HttpPut("{id}/config/{fileName}")]
|
||||
[Authorize(Roles = "owner")]
|
||||
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
if (request.Content is null)
|
||||
return Results.BadRequest(new { error = "Content is required." });
|
||||
|
||||
if (request.Content.Length > 500 * 1024)
|
||||
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
|
||||
|
||||
try
|
||||
{
|
||||
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
||||
return result is null
|
||||
? Results.BadRequest(new { error = "Invalid filename or path." })
|
||||
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
|
||||
var attempt = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
||||
var caller = DescribeCaller(HttpContext.User);
|
||||
|
||||
if (attempt.Failure is not null)
|
||||
{
|
||||
await activityRepo.AddAsync(new Data.ActivityEvent
|
||||
{
|
||||
Type = "config_audit",
|
||||
Message = $"Config save rejected agent={id} file={fileName} caller={caller} validation={attempt.Failure.Validation.Status} backup={attempt.Failure.Backup.Status} reload={attempt.Failure.ReloadCheck.Status} code={attempt.Failure.Code}",
|
||||
}, ct);
|
||||
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["content"] = attempt.Failure.Validation.Errors.ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
var result = attempt.SaveResult!;
|
||||
|
||||
await activityRepo.AddAsync(new Data.ActivityEvent
|
||||
{
|
||||
Type = "config_audit",
|
||||
Message = $"Config save agent={id} file={fileName} caller={caller} validation={result.Validation.Status} backup={result.Backup.Status} reload={result.ReloadCheck.Status}",
|
||||
}, ct);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
result.FileName,
|
||||
result.Size,
|
||||
result.ModifiedAt,
|
||||
result.Validation,
|
||||
result.Backup,
|
||||
ReloadCheck = result.ReloadCheck
|
||||
});
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
@@ -116,4 +165,92 @@ public class AgentsController(
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeCaller(ClaimsPrincipal user)
|
||||
{
|
||||
var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user.FindFirst(ClaimTypes.Email)?.Value
|
||||
?? user.Identity?.Name
|
||||
?? "unknown";
|
||||
|
||||
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
|
||||
return $"{role}:{subject}".ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AgentActivityResponse(
|
||||
long? Id,
|
||||
string Type,
|
||||
string Message,
|
||||
DateTimeOffset At,
|
||||
string Source,
|
||||
string? RelativeTime = null
|
||||
);
|
||||
|
||||
public sealed record AgentSummaryResponse(
|
||||
AgentSummaryItemResponse Now,
|
||||
AgentSummaryItemResponse Today,
|
||||
DateTimeOffset GeneratedAt
|
||||
);
|
||||
|
||||
public sealed record AgentSummaryItemResponse(
|
||||
string Text,
|
||||
string Source,
|
||||
DateTimeOffset? Timestamp
|
||||
);
|
||||
|
||||
public static class AgentSummaryBuilder
|
||||
{
|
||||
public static AgentSummaryResponse Build(
|
||||
IReadOnlyList<Nexus.Api.Data.ActivityEvent> activity,
|
||||
IReadOnlyList<Nexus.Api.Models.AgentActivityEntry> gatewayEntries,
|
||||
DateTimeOffset nowUtc)
|
||||
{
|
||||
var points = activity
|
||||
.Select(entry => new SummaryPoint(entry.Message, entry.CreatedAt, "nexus-activity"))
|
||||
.Concat(gatewayEntries.Select(entry => new SummaryPoint(entry.Text, entry.Timestamp, entry.Source)))
|
||||
.Select(point => point with { Text = AgentActivityText.RedactForDisplay(point.Text) })
|
||||
.Where(point => !string.IsNullOrWhiteSpace(point.Text))
|
||||
.OrderByDescending(point => point.Timestamp)
|
||||
.ToList();
|
||||
|
||||
var current = points.FirstOrDefault();
|
||||
var now = current is null
|
||||
? new AgentSummaryItemResponse("Keine aktuelle Aktivitaet.", "none", null)
|
||||
: new AgentSummaryItemResponse(current.Text, current.Source, current.Timestamp);
|
||||
|
||||
var windowStart = nowUtc.AddHours(-24);
|
||||
var todayPoints = points
|
||||
.Where(point => point.Timestamp >= windowStart)
|
||||
.ToList();
|
||||
|
||||
AgentSummaryItemResponse today;
|
||||
if (todayPoints.Count == 0)
|
||||
{
|
||||
today = new AgentSummaryItemResponse("Heute keine verwertbaren Checkpoints.", "none", null);
|
||||
}
|
||||
else
|
||||
{
|
||||
var snippets = todayPoints
|
||||
.Select(point => point.Text)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
|
||||
var extraCount = Math.Max(0, todayPoints.Count - snippets.Count);
|
||||
var text = $"Letzte 24h: {string.Join(" | ", snippets)}";
|
||||
if (extraCount > 0)
|
||||
text += $" (+{extraCount} weitere)";
|
||||
|
||||
var source = todayPoints.Select(point => point.Source).Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1
|
||||
? todayPoints[0].Source
|
||||
: "derived-mixed";
|
||||
|
||||
today = new AgentSummaryItemResponse(text, source, todayPoints[0].Timestamp);
|
||||
}
|
||||
|
||||
return new AgentSummaryResponse(now, today, nowUtc);
|
||||
}
|
||||
|
||||
private sealed record SummaryPoint(string Text, DateTimeOffset Timestamp, string Source);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ public class DashboardController(
|
||||
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
|
||||
=> await dashboardService.GetQueueAsync(ct);
|
||||
|
||||
[HttpGet("gateway")]
|
||||
public async Task<GatewayRuntimeInfo> GetGateway(CancellationToken ct)
|
||||
=> await dashboardService.GetGatewayInfoAsync(ct);
|
||||
|
||||
[HttpDelete("queue/{id}")]
|
||||
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
@@ -10,7 +12,11 @@ namespace Nexus.Api.Controllers;
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/tasks")]
|
||||
public class TasksController(ITaskService taskService, IAgentService agentService, IConfiguration configuration) : ControllerBase
|
||||
public class TasksController(
|
||||
ITaskService taskService,
|
||||
IAgentService agentService,
|
||||
IConfiguration configuration,
|
||||
IActivityRepository activityRepository) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetAll(CancellationToken ct)
|
||||
@@ -27,6 +33,7 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
|
||||
}
|
||||
|
||||
[HttpGet("pending-approval")]
|
||||
[Authorize(Roles = "owner")]
|
||||
public async Task<IResult> GetPendingApproval(CancellationToken ct)
|
||||
{
|
||||
var pending = await taskService.GetPendingApprovalAsync(ct);
|
||||
@@ -34,9 +41,11 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/approve")]
|
||||
[Authorize(Roles = "owner")]
|
||||
public async Task<IResult> Approve(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await taskService.ApproveAsync(id, ct);
|
||||
await WriteApprovalAuditAsync(id, "approve", result.Outcome, result.Task?.State, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
@@ -49,9 +58,11 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/reject")]
|
||||
[Authorize(Roles = "owner")]
|
||||
public async Task<IResult> Reject(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await taskService.RejectAsync(id, ct);
|
||||
await WriteApprovalAuditAsync(id, "reject", result.Outcome, result.Task?.State, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
@@ -160,4 +171,30 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
|
||||
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
|
||||
return Results.Ok(new ResetStaleResponse(count));
|
||||
}
|
||||
|
||||
private async Task WriteApprovalAuditAsync(
|
||||
Guid taskId,
|
||||
string action,
|
||||
TaskOperationOutcome outcome,
|
||||
string? state,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await activityRepository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "task_approval_audit",
|
||||
Message = $"Task approval task={taskId} action={action} caller={DescribeCaller(HttpContext.User)} outcome={outcome} checkpoint={(state ?? "none")}",
|
||||
TaskId = taskId
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private static string DescribeCaller(ClaimsPrincipal user)
|
||||
{
|
||||
var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user.FindFirst(ClaimTypes.Email)?.Value
|
||||
?? user.Identity?.Name
|
||||
?? "unknown";
|
||||
|
||||
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
|
||||
return $"{role}:{subject}".ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using ModelContextProtocol.AspNetCore;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.RateLimiting;
|
||||
@@ -202,6 +203,12 @@ public static class ServiceCollectionExtensions
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddMcpServer()
|
||||
.WithHttpTransport(options => options.Stateless = true)
|
||||
.WithTools<NexusMcpTools>();
|
||||
|
||||
services.AddOptions<StaleTaskRecoveryOptions>()
|
||||
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<LoginAttemptTracker>();
|
||||
services.AddTransient<ModelRoutingService>();
|
||||
@@ -219,6 +226,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<ILiveUpdateService, LiveUpdateService>();
|
||||
services.AddScoped<INotificationService, NotificationService>();
|
||||
services.AddScoped<ICalendarService, CalendarService>();
|
||||
services.AddScoped<IStaleTaskRecoveryService, StaleTaskRecoveryService>();
|
||||
services.AddHostedService<StaleTaskRecoveryBackgroundService>();
|
||||
|
||||
// ── Backend Bridge (Agent-Command-Service) ──
|
||||
services.AddScoped<ITaskBridgeService, TaskBridgeService>();
|
||||
|
||||
@@ -26,10 +26,10 @@ public static class PathSecurityHelper
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Validates config filename against path-traversal; must be alphanumeric .md.</summary>
|
||||
/// <summary>Validates config filename against path-traversal; must be alphanumeric .md or .json.</summary>
|
||||
public static bool IsValidConfigFileName(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return false;
|
||||
return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.md$");
|
||||
return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.(md|json)$");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ public sealed record DashboardAgentInfo(
|
||||
string? Goal = null,
|
||||
string RoleBadge = "badge-slate",
|
||||
string StatusLabel = "Bereit",
|
||||
string StatusKind = "ready",
|
||||
string? StatusDetail = null,
|
||||
string? Elapsed = null,
|
||||
string? Think = null,
|
||||
string? Next = null
|
||||
@@ -136,7 +138,22 @@ public sealed record UpdateDashboardTaskStatusRequest(
|
||||
|
||||
public sealed record AgentActivityEntry(
|
||||
string Time,
|
||||
string Text
|
||||
string Text,
|
||||
DateTimeOffset Timestamp,
|
||||
string Source = "gateway-session-history"
|
||||
);
|
||||
|
||||
public sealed record GatewayRuntimeInfo(
|
||||
bool Reachable,
|
||||
string BaseUrl,
|
||||
string? Version,
|
||||
string? RequiredVersion,
|
||||
bool VersionPinned,
|
||||
bool VersionMatches,
|
||||
string VersionStatus,
|
||||
DateTimeOffset CheckedAt,
|
||||
string? Message,
|
||||
string? Warning = null
|
||||
);
|
||||
|
||||
// ── Task Board DTOs ──
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -22,5 +22,6 @@ await app.EnsureDatabaseAsync();
|
||||
// --- Middleware Pipeline ---
|
||||
app.UseNexusPipeline(app.Environment);
|
||||
|
||||
app.MapMcp();
|
||||
app.MapControllers();
|
||||
app.Run();
|
||||
|
||||
@@ -3,7 +3,7 @@ using Nexus.Api.Data;
|
||||
|
||||
namespace Nexus.Api.Repositories;
|
||||
|
||||
public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
|
||||
public sealed class ActivityRepository(NexusDbContext db, Nexus.Api.Services.ILiveUpdateService liveUpdates) : IActivityRepository
|
||||
{
|
||||
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
|
||||
@@ -39,17 +39,35 @@ public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
|
||||
return (items, totalCount);
|
||||
}
|
||||
|
||||
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||
=> db.Activity.AsNoTracking()
|
||||
.Where(x => x.Message.Contains(agentId, StringComparison.OrdinalIgnoreCase) || x.Type == "agent")
|
||||
public async Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||
{
|
||||
var candidateCount = Math.Max(take * 8, 100);
|
||||
var recent = await db.Activity.AsNoTracking()
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(take)
|
||||
.Take(candidateCount)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return recent
|
||||
.Where(x => Nexus.Api.Services.AgentActivityText.MatchesAgent(x.Message, agentId))
|
||||
.Take(take)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||
{
|
||||
var agentIds = Nexus.Api.Services.AgentActivityText.ExtractAgentIds(activity.Message);
|
||||
activity.Message = Nexus.Api.Services.AgentActivityText.RedactForDisplay(activity.Message);
|
||||
db.Activity.Add(activity);
|
||||
await db.SaveChangesAsync(ct);
|
||||
liveUpdates.Publish("activity.created", new
|
||||
{
|
||||
activity.Id,
|
||||
activity.Type,
|
||||
activity.Message,
|
||||
activity.TaskId,
|
||||
activity.CreatedAt,
|
||||
agentIds
|
||||
}, "activity");
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ public interface ITaskRepository
|
||||
ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
|
||||
Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default);
|
||||
Task<bool> TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default);
|
||||
Task UpdateAsync(WorkTask task, CancellationToken ct = default);
|
||||
Task DeleteAsync(WorkTask task, CancellationToken ct = default);
|
||||
Task<int> CountAsync(CancellationToken ct = default);
|
||||
|
||||
@@ -27,6 +27,41 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<bool> TryResetStaleInProgressToBacklogAsync(
|
||||
Guid id,
|
||||
DateTimeOffset staleBefore,
|
||||
DateTimeOffset updatedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!db.Database.IsRelational())
|
||||
{
|
||||
var task = await db.Tasks
|
||||
.FirstOrDefaultAsync(task => task.Id == id
|
||||
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
|
||||
&& task.UpdatedAt < staleBefore, ct);
|
||||
|
||||
if (task is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||
task.UpdatedAt = updatedAt;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
var affectedRows = await db.Tasks
|
||||
.Where(task => task.Id == id
|
||||
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
|
||||
&& task.UpdatedAt < staleBefore)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(task => task.State, TaskStateHelper.ToStateString(TaskState.Backlog))
|
||||
.SetProperty(task => task.UpdatedAt, updatedAt), ct);
|
||||
|
||||
return affectedRows > 0;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
|
||||
{
|
||||
task.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Integrations": {
|
||||
"OpenClaw": {
|
||||
"BaseUrl": "http://127.0.0.1:18789",
|
||||
"RequiredVersion": "",
|
||||
"Token": "",
|
||||
"Password": ""
|
||||
},
|
||||
@@ -21,5 +22,9 @@
|
||||
"AccessTokenExpirationMinutes": 15,
|
||||
"RefreshTokenExpirationDays": 7
|
||||
},
|
||||
"TaskRecovery": {
|
||||
"StaleHours": 2,
|
||||
"IntervalMinutes": 30
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user