diff --git a/README.md b/README.md index a2086ac..566f9f1 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,63 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`): ## API endpoints +### MCP Agent Data Plane + +Nexus exposes an MCP endpoint at `/mcp` for agent-facing board operations. +It uses the official `ModelContextProtocol.AspNetCore` SDK with stateless +streamable HTTP transport. Tools are a thin facade over `ITaskBridgeService`; +they must not duplicate board business logic. + +Auth follows the bridge rules: requests provide `X-Agent-Id` and/or +`X-Nexus-Api-Key`. Secrets stay in OpenClaw/Gateway config and are never +embedded in frontend code. + +Registered tools: + +| Tool | Purpose | +|---|---| +| `nexus_get_board` | Full task board | +| `nexus_agent_overview` | Waiting/stale workflow overview | +| `nexus_get_task` | Single task | +| `nexus_get_children` | Child tasks for a parent | +| `nexus_get_activity` | Task activity history | +| `nexus_create_task` | Create parent/standalone task | +| `nexus_create_child_task` | Create visible delegation child task | +| `nexus_update_status` | Update status using the canonical enum only | +| `nexus_append_activity` | Append checkpoint/activity | +| `nexus_handoff` | Handoff to a known agent | + +The compatible `/api/bridge` HTTP facade remains available for internal +diagnostics and transition clients. New agent integrations should use MCP; +`/api/dashboard` is UI/admin surface, not an agent contract. + +### Mission Control Gateway Plane + +Nexus keeps the Browser -> Nexus -> OpenClaw boundary: the frontend never talks +to OpenClaw directly. Read-only Gateway status is exposed through +`GET /api/dashboard/gateway`; it reports reachability, discovered Gateway +version and the optional `Integrations:OpenClaw:RequiredVersion` pin. A set pin +does not mutate production config, but makes protocol drift visible in the UI. + +Agent activity shown as "Thinking" is redacted before display. Lines containing +token, password, bearer, authorization, API key or secret markers are replaced +with a redaction marker. Persisted audit-worthy events should be written as +short Activity entries, not raw session transcripts. + +Nexus activity updates stream live through the Dashboard SSE channel and are +filtered by explicit `agentIds`. Gateway session history is read-only fallback +data: it is fetched on demand, redacted before display and not persisted as a +long-term raw transcript. Agent "Now" and "Today" summaries are deterministic +derivations from redacted Nexus activity plus redacted Gateway history; Nexus +does not call an LLM to summarize this feed. + +Config writes and approval actions are owner-only. Config saves validate before +replacement, keep a `.bak` when an existing file is replaced, write audit events +without file contents or secrets and return structured `validation`, `backup` +and `reloadCheck` results. Workspace Markdown hot reload is currently reported +truthfully as `not_supported`; JSON validation exists in the save path but JSON +files are not exposed unless they are explicitly allowlisted for editing. + ### Backend Bridge (Agent-zu-Backend, NICHT Frontend) Der `/api/bridge/` Pfad ist ein strukturierter MCP-artiger Kommando-Adapter für die @@ -250,11 +307,11 @@ The Task Board now models OpenClaw delegation as a visible parent/child flow: |---|---|---| | `GET` | `/api/v1/tasks` | List all tasks | | `POST` | `/api/v1/tasks` | Create task | -| `GET` | `/api/v1/tasks/pending-approval` | Tasks in progress older than 1 hour | +| `GET` | `/api/v1/tasks/pending-approval` | Owner-only pending approvals | | `PATCH` | `/api/v1/tasks/{id}` | Update task (title, priority, projectId) | | `PATCH` | `/api/v1/tasks/{id}/state` | Update task state | -| `POST` | `/api/v1/tasks/{id}/approve` | Approve task (in-progress → done) | -| `POST` | `/api/v1/tasks/{id}/reject` | Reject task (in-progress → backlog) | +| `POST` | `/api/v1/tasks/{id}/approve` | Owner-only approve task (in-progress -> done) | +| `POST` | `/api/v1/tasks/{id}/reject` | Owner-only reject task (in-progress -> backlog) | | `DELETE` | `/api/v1/tasks/{id}` | Delete task (only done/backlog states) | ### Agents @@ -264,10 +321,11 @@ The Task Board now models OpenClaw delegation as a visible parent/child flow: | `GET` | `/api/v1/agents` | List all agents | | `GET` | `/api/v1/agents/{id}` | Agent detail (with sub-agents, identity) | | `GET` | `/api/v1/agents/{id}/activity` | Agent-specific activity (last 50) | +| `GET` | `/api/v1/agents/{id}/summary` | Redacted deterministic Now/Today summary | | `POST` | `/api/v1/agents/{id}/command` | Send command to agent | | `GET` | `/api/v1/agents/{id}/config` | List agent config files (IDENTITY.md, SOUL.md, etc.) | | `GET` | `/api/v1/agents/{id}/config/{fileName}` | Read config file content | -| `PUT` | `/api/v1/agents/{id}/config/{fileName}` | Save config file (atomic write) | +| `PUT` | `/api/v1/agents/{id}/config/{fileName}` | Owner-only validated config save with backup/audit/reload result | ### Memory & Docs diff --git a/backend-tests/MissionControlPhaseTests.cs b/backend-tests/MissionControlPhaseTests.cs new file mode 100644 index 0000000..a3daf5c --- /dev/null +++ b/backend-tests/MissionControlPhaseTests.cs @@ -0,0 +1,496 @@ +using System.Reflection; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Nexus.Api.Data; +using Nexus.Api.Controllers; +using Nexus.Api.DTOs; +using Nexus.Api.Models; +using Nexus.Api.Repositories; +using Nexus.Api.Services; +using Xunit; + +namespace Nexus.Api.Tests; + +public sealed class MissionControlPhaseTests +{ + [Fact] + public void AgentConfigSave_IsBaoOwnerOnly() + { + var method = typeof(AgentsController).GetMethod(nameof(AgentsController.SaveConfigFile), BindingFlags.Instance | BindingFlags.Public); + + Assert.NotNull(method); + var authorize = method!.GetCustomAttribute(); + Assert.NotNull(authorize); + Assert.Equal("owner", authorize!.Roles); + } + + [Fact] + public void TaskApprovalEndpoints_AreOwnerOnly() + { + var pending = typeof(TasksController).GetMethod(nameof(TasksController.GetPendingApproval), BindingFlags.Instance | BindingFlags.Public); + var approve = typeof(TasksController).GetMethod(nameof(TasksController.Approve), BindingFlags.Instance | BindingFlags.Public); + var reject = typeof(TasksController).GetMethod(nameof(TasksController.Reject), BindingFlags.Instance | BindingFlags.Public); + + Assert.Equal("owner", pending!.GetCustomAttribute()?.Roles); + Assert.Equal("owner", approve!.GetCustomAttribute()?.Roles); + Assert.Equal("owner", reject!.GetCustomAttribute()?.Roles); + } + + [Fact] + public void GatewayActivityRedaction_RemovesSensitiveLines() + { + var text = OpenClawGatewayClient.RedactSensitiveText(""" + Status: ok + Authorization: Bearer abc.def.ghi + Next step ready + X-Nexus-Api-Key: secret + """); + + Assert.Contains("Status: ok", text); + Assert.Contains("Next step ready", text); + Assert.DoesNotContain("Bearer abc", text); + Assert.DoesNotContain("secret", text); + Assert.Equal(2, text.Split("[redacted sensitive line]").Length - 1); + } + + [Fact] + public void AgentSummaryBuilder_ProducesStructuredNowAndTodaySummary() + { + var now = DateTimeOffset.UtcNow; + var activity = new[] + { + new ActivityEvent + { + Type = "agent_task", + Message = "programmer completed repo scan", + CreatedAt = now.AddHours(-3) + } + }; + + var gateway = new[] + { + new AgentActivityEntry("5m ago", "Authorization: Bearer hidden\nWorking on redaction", now.AddMinutes(-5)), + new AgentActivityEntry("20m ago", "Checking task mapping", now.AddMinutes(-20)) + }; + + var summary = AgentSummaryBuilder.Build(activity, gateway, now); + + Assert.Equal("gateway-session-history", summary.Now.Source); + Assert.Equal(now.AddMinutes(-5), summary.Now.Timestamp); + Assert.DoesNotContain("Bearer hidden", summary.Now.Text); + Assert.Contains("Working on redaction", summary.Now.Text); + Assert.Equal("derived-mixed", summary.Today.Source); + Assert.Equal(now.AddMinutes(-5), summary.Today.Timestamp); + Assert.Contains("Working on redaction", summary.Today.Text); + Assert.Contains("Checking task mapping", summary.Today.Text); + Assert.Contains("programmer completed repo scan", summary.Today.Text); + } + + [Fact] + public async Task ActivityRepository_RedactsBeforePersistenceAndPublishesAgentIds() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + await using var db = new NexusDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var liveUpdates = new LiveUpdateService(); + var subscription = await liveUpdates.SubscribeAsync(); + var repository = new ActivityRepository(db, liveUpdates); + + await repository.AddAsync(new ActivityEvent + { + Type = "agent", + Message = "Command sent to agent programmer: Authorization: Bearer secret-token" + }); + + var stored = await repository.GetRecentAsync(1); + Assert.Single(stored); + Assert.DoesNotContain("secret-token", stored[0].Message); + Assert.Contains("programmer", stored[0].Message); + Assert.Contains("Authorization: Bearer [redacted]", stored[0].Message); + + var envelope = await subscription.Reader.ReadAsync(); + Assert.Equal("activity.created", envelope.Type); + + var payloadJson = JsonSerializer.Serialize(envelope.Payload); + using var doc = JsonDocument.Parse(payloadJson); + Assert.Equal("agent", doc.RootElement.GetProperty("Type").GetString()); + Assert.DoesNotContain("secret-token", doc.RootElement.GetProperty("Message").GetString()); + var agentIds = doc.RootElement.GetProperty("agentIds").EnumerateArray().Select(x => x.GetString()).ToArray(); + Assert.Contains("programmer", agentIds); + } + + [Fact] + public async Task ActivityRepository_GetByAgentAsync_UsesMappedAgentIds() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + await using var db = new NexusDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var repository = new ActivityRepository(db, new LiveUpdateService()); + await repository.AddAsync(new ActivityEvent + { + Type = "agent", + Message = "Command sent to agent programmer: compile module" + }); + await repository.AddAsync(new ActivityEvent + { + Type = "agent", + Message = "Command sent to agent reviewer: inspect module" + }); + + var programmerEvents = await repository.GetByAgentAsync("programmer", 10); + + Assert.Single(programmerEvents); + Assert.True(programmerEvents[0].Message.Contains("programmer", StringComparison.OrdinalIgnoreCase)); + Assert.False(programmerEvents[0].Message.Contains("reviewer", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task GatewayInfo_ReportsVersionDrift() + { + var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent("""{"version":"2026.07.08"}""", Encoding.UTF8, "application/json") + }, requiredVersion: "2026.07.09"); + + var info = await client.GetGatewayInfoAsync(); + + Assert.True(info.Reachable); + Assert.Equal("2026.07.08", info.Version); + Assert.Equal("2026.07.09", info.RequiredVersion); + Assert.Equal("drift", info.VersionStatus); + Assert.False(info.VersionMatches); + Assert.NotNull(info.Warning); + Assert.Contains("2026.07.08", info.Warning!); + } + + [Fact] + public async Task GatewayInfo_ReportsMissingVersionWhenPinned() + { + var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json") + }, requiredVersion: "2026.07.09"); + + var info = await client.GetGatewayInfoAsync(); + + Assert.True(info.Reachable); + Assert.Null(info.Version); + Assert.Equal("missing", info.VersionStatus); + Assert.False(info.VersionMatches); + Assert.NotNull(info.Warning); + Assert.Contains("2026.07.09", info.Warning!); + } + + [Fact] + public async Task GatewayInfo_ReportsMatchedPinnedVersion() + { + var client = CreateClient(request => + { + var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json") + }; + response.Headers.Add("X-OpenClaw-Version", "2026.07.09"); + return response; + }, requiredVersion: "2026.07.09"); + + var info = await client.GetGatewayInfoAsync(); + + Assert.True(info.Reachable); + Assert.Equal("matched", info.VersionStatus); + Assert.True(info.VersionMatches); + Assert.Null(info.Warning); + } + + [Fact] + public async Task GetAgentsAsync_MapsRuntimeStatesFromGatewayStatus() + { + var staleTimestamp = DateTimeOffset.UtcNow.AddMinutes(-40).ToString("o"); + var client = CreateClient(request => + { + if (request.RequestUri?.AbsolutePath == "/tools/invoke") + { + using var doc = JsonDocument.Parse(request.Content!.ReadAsStringAsync().GetAwaiter().GetResult()); + var agentId = doc.RootElement.GetProperty("args").GetProperty("sessionKey").GetString()! + .Split(':', StringSplitOptions.RemoveEmptyEntries)[1]; + + object status = agentId switch + { + "iris" => new { status = "active", isActive = true, currentTask = "Coordinate launch", model = "openai/gpt-5.5" }, + "programmer" => new { status = "idle", lastActivity = staleTimestamp, model = "openai/gpt-5.4" }, + "reviewer" => new { status = "failed", error = "gateway timeout", model = "openai/gpt-5.5" }, + "architekt" => new { status = "unsupported", message = "tool not available", model = "openai/gpt-5.5" }, + _ => new { status = "ready", model = "openai/gpt-5.5" } + }; + + return ToolResult(status); + } + + return new HttpResponseMessage(System.Net.HttpStatusCode.NotFound); + }, agentIds: ["iris", "programmer", "reviewer", "architekt"]); + + var agents = await client.GetAgentsAsync(); + + Assert.Collection(agents.OrderBy(a => a.Id), + architekt => + { + Assert.Equal("architekt", architekt.Id); + Assert.Equal("unsupported", architekt.StatusKind); + Assert.Equal("Unsupported", architekt.StatusLabel); + Assert.Equal("tool not available", architekt.StatusDetail); + }, + iris => + { + Assert.Equal("iris", iris.Id); + Assert.Equal("connected", iris.StatusKind); + Assert.Equal("Arbeitet", iris.StatusLabel); + }, + programmer => + { + Assert.Equal("programmer", programmer.Id); + Assert.Equal("stale", programmer.StatusKind); + Assert.Equal("Stale", programmer.StatusLabel); + Assert.NotNull(programmer.StatusDetail); + Assert.Contains("40m", programmer.StatusDetail!); + }, + reviewer => + { + Assert.Equal("reviewer", reviewer.Id); + Assert.Equal("error", reviewer.StatusKind); + Assert.Equal("Fehler", reviewer.StatusLabel); + Assert.Equal("gateway timeout", reviewer.StatusDetail); + }); + } + + [Fact] + public async Task AgentConfigService_RejectsNullBytesBeforeReplacingFile() + { + var agentId = $"phase-p4-{Guid.NewGuid():N}"; + var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}"); + Directory.CreateDirectory(workspacePath); + var configPath = Path.Combine(workspacePath, "TOOLS.md"); + await File.WriteAllTextAsync(configPath, "original"); + + try + { + var service = new AgentConfigService(); + var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "bad\0content"); + + Assert.NotNull(attempt.Failure); + Assert.Equal("validation_failed", attempt.Failure!.Code); + Assert.Equal("failed", attempt.Failure.Validation.Status); + Assert.Contains(attempt.Failure.Validation.Errors, error => error.Contains("null bytes", StringComparison.OrdinalIgnoreCase)); + Assert.Equal("original", await File.ReadAllTextAsync(configPath)); + } + finally + { + Directory.Delete(workspacePath, recursive: true); + } + } + + [Fact] + public async Task AgentConfigService_ReturnsBackupAndReloadShape_OnSuccessfulSave() + { + var agentId = $"phase-p4-{Guid.NewGuid():N}"; + var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}"); + Directory.CreateDirectory(workspacePath); + var configPath = Path.Combine(workspacePath, "TOOLS.md"); + await File.WriteAllTextAsync(configPath, "before"); + + try + { + var service = new AgentConfigService(); + var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "after"); + + Assert.NotNull(attempt.SaveResult); + var result = attempt.SaveResult!; + Assert.Equal("passed", result.Validation.Status); + Assert.Equal("markdown", result.Validation.FileKind); + Assert.Equal("created", result.Backup.Status); + Assert.True(result.Backup.BackupCreated); + Assert.Equal("not_supported", result.ReloadCheck.Status); + Assert.False(string.IsNullOrWhiteSpace(result.ReloadCheck.Message)); + Assert.Equal("before", await File.ReadAllTextAsync(configPath + ".bak")); + Assert.Equal("after", await File.ReadAllTextAsync(configPath)); + } + finally + { + Directory.Delete(workspacePath, recursive: true); + } + } + + [Fact] + public async Task AgentConfigSave_AuditsFailureWithoutLeakingContent() + { + var configService = new FakeAgentConfigService(new AgentConfigSaveAttempt( + null, + new AgentConfigSaveFailure( + "validation_failed", + new AgentConfigValidationResult("failed", "markdown", ["Content contains null bytes."]), + new AgentConfigBackupResult("not_applicable", false), + new AgentConfigReloadCheckResult("not_supported", "No hot reload available.")))); + var activityRepo = new CapturingActivityRepository(); + + var controller = new AgentsController( + new FakeAgentService(), + new FakeAgentRuntime(), + activityRepo, + configService, + new FakeDashboardService(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, "bao"), + new Claim(ClaimTypes.Role, "owner") + ], "TestAuth")) + } + } + }; + + var result = await controller.SaveConfigFile("programmer", "TOOLS.md", new SaveConfigRequest("secret\0payload"), CancellationToken.None); + + var statusResult = Assert.IsAssignableFrom(result); + Assert.Equal(StatusCodes.Status400BadRequest, statusResult.StatusCode); + var audit = Assert.Single(activityRepo.Added); + Assert.Equal("config_audit", audit.Type); + Assert.Contains("validation=failed", audit.Message); + Assert.DoesNotContain("secret", audit.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("payload", audit.Message, StringComparison.OrdinalIgnoreCase); + } + + private static OpenClawGatewayClient CreateClient( + Func responder, + string? requiredVersion = null, + string[]? agentIds = null) + { + var configValues = new Dictionary + { + ["Integrations:OpenClaw:RequiredVersion"] = requiredVersion + }; + + if (agentIds is not null) + { + var configPath = Path.GetTempFileName(); + File.WriteAllText(configPath, JsonSerializer.Serialize(new + { + agents = new + { + list = agentIds.Select(id => new { id }).ToArray() + } + })); + configValues["AgentConfigPath"] = configPath; + } + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configValues) + .Build(); + + var httpClient = new HttpClient(new StubHttpMessageHandler(responder)) + { + BaseAddress = new Uri("http://gateway.local") + }; + + return new OpenClawGatewayClient(httpClient, configuration); + } + + private static HttpResponseMessage ToolResult(object payload) + => new(System.Net.HttpStatusCode.OK) + { + Content = new StringContent( + JsonSerializer.Serialize(new { ok = true, result = payload }), + Encoding.UTF8, + "application/json") + }; +} + +file sealed class StubHttpMessageHandler(Func responder) : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(responder(request)); +} + +file sealed class FakeAgentConfigService(AgentConfigSaveAttempt attempt) : IAgentConfigService +{ + public IReadOnlyList GetConfigFiles(string agentId) => []; + + public Task GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default) + => Task.FromResult(null); + + public Task SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default) + => Task.FromResult(attempt); +} + +file sealed class CapturingActivityRepository : IActivityRepository +{ + public List Added { get; } = []; + + public Task> GetRecentAsync(int take, CancellationToken ct = default) => Task.FromResult(new List()); + public Task> GetRecentForTasksAsync(IEnumerable taskIds, CancellationToken ct = default) => Task.FromResult(new List()); + public Task<(List Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default) + => Task.FromResult((new List(), 0)); + public Task> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) => Task.FromResult(new List()); + public Task AddAsync(ActivityEvent activity, CancellationToken ct = default) + { + Added.Add(activity); + return Task.FromResult(activity); + } +} + +file sealed class FakeAgentService : IAgentService +{ + public Task> GetAgentsAsync(CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task GetAgentAsync(string id, CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task> GetAllowedAgentIdsAsync(CancellationToken cancellationToken) + => Task.FromResult>(new HashSet(StringComparer.OrdinalIgnoreCase) { "iris", "bao", "programmer" }); +} + +file sealed class FakeAgentRuntime : Nexus.Api.Integrations.IAgentRuntime +{ + public string Name => "fake"; + + public Task GetStatusAsync(CancellationToken cancellationToken) + => Task.FromResult(new Nexus.Api.Integrations.AgentRuntimeStatus("fake", OperationalStatus.Online, TimeSpan.Zero, null)); + + public Task ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken) + => Task.FromResult(new Nexus.Api.Integrations.AgentChatResult("fake", agentId, conversationId, "ok")); +} + +file sealed class FakeDashboardService : IDashboardService +{ + public Task GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0)); + public Task> GetAgentsAsync() => Task.FromResult(new List()); + public Task> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List()); + public Task SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null)); + public Task> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List()); + public Task> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List()); + public Task GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok")); + public Task DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored)); + public Task CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored)); + public Task GetAgentModelAsync(string agentId) => Task.FromResult(null); + public Task SetAgentModelAsync(string agentId, string model) => Task.FromResult(false); + public Task> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List()); + public List GetAvailableModels() => []; +} diff --git a/backend-tests/NexusMcpToolsTests.cs b/backend-tests/NexusMcpToolsTests.cs new file mode 100644 index 0000000..8235181 --- /dev/null +++ b/backend-tests/NexusMcpToolsTests.cs @@ -0,0 +1,149 @@ +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Server; +using Nexus.Api.Controllers; +using Nexus.Api.Data; +using Nexus.Api.Models; +using Nexus.Api.Services; +using Xunit; + +namespace Nexus.Api.Tests; + +public sealed class NexusMcpToolsTests +{ + [Fact] + public void NexusMcpTools_RegistersExpectedToolNames() + { + var toolNames = typeof(NexusMcpTools) + .GetMethods() + .Select(method => method.GetCustomAttributes(typeof(McpServerToolAttribute), inherit: false) + .OfType() + .FirstOrDefault()) + .Where(attribute => attribute is not null) + .Select(attribute => attribute!.Name ?? string.Empty) + .Order() + .ToArray(); + + Assert.Equal( + [ + "nexus_agent_overview", + "nexus_append_activity", + "nexus_create_child_task", + "nexus_create_task", + "nexus_get_activity", + "nexus_get_board", + "nexus_get_children", + "nexus_get_task", + "nexus_handoff", + "nexus_update_status" + ], toolNames); + } + + [Fact] + public void NexusMcpTaskState_OnlyContainsCanonicalStates() + { + Assert.Equal( + [ + nameof(NexusMcpTaskState.Backlog), + nameof(NexusMcpTaskState.InProgress), + nameof(NexusMcpTaskState.Blocked), + nameof(NexusMcpTaskState.Done), + nameof(NexusMcpTaskState.Review) + ], Enum.GetNames()); + + Assert.DoesNotContain("Delegated", Enum.GetNames()); + } + + [Fact] + public async Task McpTools_ReadAndWrite_UseBridgeService() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + fixture.SetCallerAgent("iris"); + var tools = CreateTools(fixture); + + var createResult = await tools.CreateTask( + title: "MCP parent", + detail: "Created through MCP tool facade", + priority: "High", + assignedTo: "iris", + ct: CancellationToken.None); + + Assert.True(createResult.Ok); + Assert.NotNull(createResult.Data); + Assert.Equal("MCP parent", createResult.Data!.Title); + + var activityResult = await tools.AppendActivity( + createResult.Data.Id, + "MCP checkpoint", + "comment", + CancellationToken.None); + + Assert.True(activityResult.Ok); + Assert.Equal("MCP checkpoint", activityResult.Data!.Message); + + var statusResult = await tools.UpdateStatus( + createResult.Data.Id, + NexusMcpTaskState.InProgress, + CancellationToken.None); + + Assert.True(statusResult.Ok); + Assert.Equal(TaskStateHelper.ToStateString(TaskState.InProgress), statusResult.Data!.State); + + var board = await tools.GetBoard(CancellationToken.None); + Assert.Contains(board.InProgress, task => task.Id == createResult.Data.Id); + + var taskResult = await tools.GetTask(createResult.Data.Id, CancellationToken.None); + Assert.True(taskResult.Ok); + Assert.Equal("MCP parent", taskResult.Data!.Title); + } + + [Fact] + public async Task McpTools_UpdateStatus_RejectsUnauthorizedAgent() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + var task = await fixture.TaskService.CreateDashboardTaskAsync( + "Programmer cannot move", + "State changes stay with Iris/Bao.", + "iris", + "Normal", + "programmer", + null, + CancellationToken.None); + + fixture.SetCallerAgent("programmer"); + var tools = CreateTools(fixture); + + var result = await tools.UpdateStatus(task.Id, NexusMcpTaskState.Done, CancellationToken.None); + + Assert.False(result.Ok); + Assert.Equal("nexus_update_status", result.Command); + Assert.Contains("not authorized", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task McpTools_ServiceKey_ResolvesAsNexusSystem() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + fixture.HttpContextAccessor.HttpContext = TaskWorkflowFixture.CreateHttpContext( + headers: new Dictionary + { + ["X-Nexus-Api-Key"] = "test-service-key" + }); + + var tools = CreateTools(fixture); + var result = await tools.CreateTask( + title: "System MCP task", + assignedTo: "iris", + ct: CancellationToken.None); + + Assert.True(result.Ok); + Assert.Equal("bao", result.Data!.Source); + } + + private static NexusMcpTools CreateTools(TaskWorkflowFixture fixture) + => new( + fixture.TaskBridgeService, + fixture.AgentService, + fixture.HttpContextAccessor, + fixture.Configuration, + NullLogger.Instance); +} diff --git a/backend-tests/OperationsSnapshotTests.cs b/backend-tests/OperationsSnapshotTests.cs index c901950..b99fbbb 100644 --- a/backend-tests/OperationsSnapshotTests.cs +++ b/backend-tests/OperationsSnapshotTests.cs @@ -94,6 +94,8 @@ internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) : public ValueTask GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); public Task> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException(); public Task AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException(); + public Task TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default) + => throw new NotSupportedException(); public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException(); public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException(); public Task CountAsync(CancellationToken ct = default) => throw new NotSupportedException(); diff --git a/backend-tests/StaleTaskRecoveryTests.cs b/backend-tests/StaleTaskRecoveryTests.cs new file mode 100644 index 0000000..e1ee878 --- /dev/null +++ b/backend-tests/StaleTaskRecoveryTests.cs @@ -0,0 +1,348 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Nexus.Api.Data; +using Nexus.Api.Models; +using Nexus.Api.Repositories; +using Nexus.Api.Services; +using Xunit; + +namespace Nexus.Api.Tests; + +public sealed class StaleTaskRecoveryTests +{ + [Fact] + public async Task ResetStaleInProgressTasksAsync_OnlyResetsStaleInProgressTasks_AndWritesActivity() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3); + + var staleInProgress = await fixture.TaskRepository.AddAsync(new WorkTask + { + Title = "Stale in progress", + State = "In progress", + Source = "iris", + UpdatedAt = staleTimestamp, + CreatedAt = staleTimestamp + }, CancellationToken.None); + + await fixture.ActivityRepository.AddAsync(new ActivityEvent + { + Type = "comment", + Message = "Previous agent note", + TaskId = staleInProgress.Id, + CreatedAt = staleTimestamp.AddMinutes(15) + }, CancellationToken.None); + + var staleBlocked = await fixture.TaskRepository.AddAsync(new WorkTask + { + Title = "Blocked task", + State = "Blocked", + Source = "iris", + UpdatedAt = staleTimestamp, + CreatedAt = staleTimestamp + }, CancellationToken.None); + + var staleReview = await fixture.TaskRepository.AddAsync(new WorkTask + { + Title = "Review task", + State = "Review", + Source = "iris", + UpdatedAt = staleTimestamp, + CreatedAt = staleTimestamp + }, CancellationToken.None); + + var staleDone = await fixture.TaskRepository.AddAsync(new WorkTask + { + Title = "Done task", + State = "Done", + Source = "iris", + UpdatedAt = staleTimestamp, + CreatedAt = staleTimestamp + }, CancellationToken.None); + + var staleBacklog = await fixture.TaskRepository.AddAsync(new WorkTask + { + Title = "Backlog task", + State = "Backlog", + Source = "iris", + UpdatedAt = staleTimestamp, + CreatedAt = staleTimestamp + }, CancellationToken.None); + + var freshInProgress = await fixture.TaskRepository.AddAsync(new WorkTask + { + Title = "Fresh in progress", + State = "In progress", + Source = "iris", + UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-30), + CreatedAt = staleTimestamp + }, CancellationToken.None); + + var resetCount = await fixture.StaleTaskRecoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None); + + Assert.Equal(1, resetCount); + Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleInProgress.Id, CancellationToken.None))!.State); + Assert.Equal("Blocked", (await fixture.TaskService.GetByIdAsync(staleBlocked.Id, CancellationToken.None))!.State); + Assert.Equal("Review", (await fixture.TaskService.GetByIdAsync(staleReview.Id, CancellationToken.None))!.State); + Assert.Equal("Done", (await fixture.TaskService.GetByIdAsync(staleDone.Id, CancellationToken.None))!.State); + Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleBacklog.Id, CancellationToken.None))!.State); + Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(freshInProgress.Id, CancellationToken.None))!.State); + + var activity = await fixture.TaskService.GetTaskActivityAsync(staleInProgress.Id, CancellationToken.None); + var resetActivity = activity.FirstOrDefault(entry => entry.Message.Contains("stale recovery", StringComparison.Ordinal)); + + Assert.NotNull(resetActivity); + Assert.Contains("reason=stale-recovery", resetActivity!.Message, StringComparison.Ordinal); + Assert.Contains("previous status In progress", resetActivity.Message, StringComparison.Ordinal); + Assert.Contains("stale reference", resetActivity.Message, StringComparison.Ordinal); + Assert.Contains("last activity", resetActivity.Message, StringComparison.Ordinal); + Assert.Contains("new status Backlog", resetActivity.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ResetStaleInProgressTasksAsync_RevalidatesCurrentTaskBeforeReset() + { + var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3); + var taskId = Guid.NewGuid(); + var staleCandidate = new WorkTask + { + Id = taskId, + Title = "Changed during recovery scan", + State = "In progress", + Source = "iris", + UpdatedAt = staleTimestamp, + CreatedAt = staleTimestamp + }; + var currentTask = new WorkTask + { + Id = taskId, + Title = "Changed during recovery scan", + State = "Review", + Source = "iris", + UpdatedAt = staleTimestamp, + CreatedAt = staleTimestamp + }; + var taskRepository = new FakeTaskRepository(staleCandidate, currentTask); + var activityRepository = new FakeActivityRepository(); + var liveUpdateService = new FakeLiveUpdateService(); + var recoveryService = new StaleTaskRecoveryService( + taskRepository, + activityRepository, + liveUpdateService); + + var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None); + + Assert.Equal(0, resetCount); + Assert.Equal("Review", currentTask.State); + Assert.Equal(0, taskRepository.ResetCount); + Assert.Empty(activityRepository.Added); + Assert.Equal(0, liveUpdateService.PublishCount); + } + + [Fact] + public async Task BackgroundService_RunRecoveryOnceAsync_UsesConfiguredThreshold_AndCallsRecoveryService() + { + var fakeRecoveryService = new FakeStaleTaskRecoveryService(); + var services = new ServiceCollection(); + services.AddScoped(_ => fakeRecoveryService); + + await using var provider = services.BuildServiceProvider(); + var backgroundService = new StaleTaskRecoveryBackgroundService( + provider.GetRequiredService(), + new TestOptionsMonitor(new StaleTaskRecoveryOptions + { + StaleHours = 4, + IntervalMinutes = 30 + }), + NullLogger.Instance); + + var resetCount = await backgroundService.RunRecoveryOnceAsync(CancellationToken.None); + + Assert.Equal(1, fakeRecoveryService.CallCount); + Assert.Equal(TimeSpan.FromHours(4), fakeRecoveryService.LastThreshold); + Assert.Equal(7, resetCount); + } + + [Fact] + public async Task BackgroundService_StartAsync_RunsRecoveryWithoutWaitingForFullInterval() + { + var fakeRecoveryService = new FakeStaleTaskRecoveryService(); + var firstCall = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + fakeRecoveryService.OnCall = () => firstCall.TrySetResult(true); + + var services = new ServiceCollection(); + services.AddScoped(_ => fakeRecoveryService); + + await using var provider = services.BuildServiceProvider(); + var backgroundService = new StaleTaskRecoveryBackgroundService( + provider.GetRequiredService(), + new TestOptionsMonitor(new StaleTaskRecoveryOptions + { + StaleHours = 2, + IntervalMinutes = 30 + }), + NullLogger.Instance); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await backgroundService.StartAsync(cts.Token); + await firstCall.Task.WaitAsync(cts.Token); + await backgroundService.StopAsync(CancellationToken.None); + + Assert.True(fakeRecoveryService.CallCount >= 1); + Assert.Equal(TimeSpan.FromHours(2), fakeRecoveryService.LastThreshold); + } + + [Fact] + public void TaskRecoveryOptions_BindsStaleHoursFromEnvironmentOverride() + { + const string key = "TaskRecovery__StaleHours"; + var originalValue = Environment.GetEnvironmentVariable(key); + + try + { + Environment.SetEnvironmentVariable(key, "5"); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{StaleTaskRecoveryOptions.SectionName}:StaleHours"] = "2", + [$"{StaleTaskRecoveryOptions.SectionName}:IntervalMinutes"] = "30" + }) + .AddEnvironmentVariables() + .Build(); + + var options = configuration.GetSection(StaleTaskRecoveryOptions.SectionName).Get(); + + Assert.NotNull(options); + Assert.Equal(5, options!.StaleHours); + Assert.Equal(30, options.IntervalMinutes); + } + finally + { + Environment.SetEnvironmentVariable(key, originalValue); + } + } +} + +file sealed class FakeTaskRepository(WorkTask staleCandidate, WorkTask currentTask) : ITaskRepository +{ + public int ResetCount { get; private set; } + + public Task> GetAllAsync(CancellationToken ct = default) + => Task.FromResult(new List { staleCandidate }); + + public ValueTask GetByIdAsync(Guid id, CancellationToken ct = default) + => ValueTask.FromResult(id == currentTask.Id ? currentTask : null); + + public Task TryResetStaleInProgressToBacklogAsync( + Guid id, + DateTimeOffset staleBefore, + DateTimeOffset updatedAt, + CancellationToken ct = default) + { + if (id != currentTask.Id + || !string.Equals(currentTask.State, "In progress", StringComparison.OrdinalIgnoreCase) + || currentTask.UpdatedAt >= staleBefore) + { + return Task.FromResult(false); + } + + ResetCount++; + currentTask.State = "Backlog"; + currentTask.UpdatedAt = updatedAt; + return Task.FromResult(true); + } + + public Task UpdateAsync(WorkTask task, CancellationToken ct = default) + { + return Task.CompletedTask; + } + + public Task> GetPendingApprovalAsync(CancellationToken ct = default) + => Task.FromResult(new List()); + + public Task AddAsync(WorkTask task, CancellationToken ct = default) + => Task.FromResult(task); + + public Task DeleteAsync(WorkTask task, CancellationToken ct = default) + => Task.CompletedTask; + + public Task CountAsync(CancellationToken ct = default) + => Task.FromResult(0); + + public Task CountByStateAsync(string state, CancellationToken ct = default) + => Task.FromResult(0); + + public Task GetLastBlockedAsync(CancellationToken ct = default) + => Task.FromResult(null); +} + +file sealed class FakeActivityRepository : IActivityRepository +{ + public List Added { get; } = []; + + public Task> GetRecentAsync(int take, CancellationToken ct = default) + => Task.FromResult(new List()); + + public Task> GetRecentForTasksAsync(IEnumerable taskIds, CancellationToken ct = default) + => Task.FromResult(new List()); + + public Task<(List Items, int TotalCount)> GetPagedAsync( + string? type, + string? sort, + int page, + int pageSize, + CancellationToken ct = default) + => Task.FromResult((new List(), 0)); + + public Task> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) + => Task.FromResult(new List()); + + public Task AddAsync(ActivityEvent activity, CancellationToken ct = default) + { + Added.Add(activity); + return Task.FromResult(activity); + } +} + +file sealed class FakeLiveUpdateService : ILiveUpdateService +{ + public int PublishCount { get; private set; } + public long CurrentSequence => PublishCount; + + public Task SubscribeAsync(long? afterSequence = null, CancellationToken ct = default) + => throw new NotSupportedException(); + + public LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard") + { + PublishCount++; + return new LiveUpdateEnvelope(type, DateTimeOffset.UtcNow, payload, PublishCount, channel); + } +} + +file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService +{ + public int CallCount { get; private set; } + public TimeSpan LastThreshold { get; private set; } + public Action? OnCall { get; set; } + + public Task ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default) + { + CallCount++; + LastThreshold = staleThreshold; + OnCall?.Invoke(); + return Task.FromResult(7); + } +} + +file sealed class TestOptionsMonitor(T currentValue) : IOptionsMonitor +{ + public T CurrentValue { get; private set; } = currentValue; + + public T Get(string? name) => CurrentValue; + + public IDisposable? OnChange(Action listener) => null; +} diff --git a/backend-tests/TaskWorkflowTests.cs b/backend-tests/TaskWorkflowTests.cs index 6f765a7..aa22274 100644 --- a/backend-tests/TaskWorkflowTests.cs +++ b/backend-tests/TaskWorkflowTests.cs @@ -178,7 +178,7 @@ public sealed class TaskWorkflowTests { await using var fixture = await TaskWorkflowFixture.CreateAsync(); - var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration) + var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository) { ControllerContext = new ControllerContext { @@ -199,7 +199,7 @@ public sealed class TaskWorkflowTests { await using var fixture = await TaskWorkflowFixture.CreateAsync(); - var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration) + var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository) { ControllerContext = new ControllerContext { @@ -217,7 +217,7 @@ public sealed class TaskWorkflowTests { await using var fixture = await TaskWorkflowFixture.CreateAsync(); - var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration) + var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository) { ControllerContext = new ControllerContext { @@ -238,7 +238,7 @@ public sealed class TaskWorkflowTests { await using var fixture = await TaskWorkflowFixture.CreateAsync(); - var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration) + var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository) { ControllerContext = new ControllerContext { @@ -256,7 +256,7 @@ public sealed class TaskWorkflowTests { await using var fixture = await TaskWorkflowFixture.CreateAsync(); - var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration) + var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository) { ControllerContext = new ControllerContext { @@ -277,7 +277,7 @@ public sealed class TaskWorkflowTests { await using var fixture = await TaskWorkflowFixture.CreateAsync(); - var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration) + var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository) { ControllerContext = new ControllerContext { @@ -372,7 +372,7 @@ public sealed class TaskWorkflowTests } } -file sealed class TaskWorkflowFixture : IAsyncDisposable +internal sealed class TaskWorkflowFixture : IAsyncDisposable { private readonly NexusDbContext _db; @@ -383,6 +383,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable IActivityRepository activityRepository, INotificationService notificationService, ILiveUpdateService liveUpdateService, + IStaleTaskRecoveryService staleTaskRecoveryService, ITaskService taskService, ITaskBridgeService taskBridgeService, IAgentService agentService, @@ -394,6 +395,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable ActivityRepository = activityRepository; NotificationService = notificationService; LiveUpdateService = liveUpdateService; + StaleTaskRecoveryService = staleTaskRecoveryService; TaskService = taskService; TaskBridgeService = taskBridgeService; AgentService = agentService; @@ -405,6 +407,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable public IActivityRepository ActivityRepository { get; } public INotificationService NotificationService { get; } public ILiveUpdateService LiveUpdateService { get; } + public IStaleTaskRecoveryService StaleTaskRecoveryService { get; } public ITaskService TaskService { get; } public ITaskBridgeService TaskBridgeService { get; } public IAgentService AgentService { get; } @@ -430,10 +433,14 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable var agentService = new AgentService(configuration, new FakeRuntime()); var liveUpdateService = new LiveUpdateService(); - var activityRepository = new ActivityRepository(db); + var activityRepository = new ActivityRepository(db, liveUpdateService); var taskRepository = new TaskRepository(db); var notificationService = new NotificationService(db, liveUpdateService); var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") }; + var staleTaskRecoveryService = new StaleTaskRecoveryService( + taskRepository, + activityRepository, + liveUpdateService); var taskService = new TaskService( taskRepository, @@ -441,7 +448,8 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable notificationService, agentService, httpContextAccessor, - liveUpdateService); + liveUpdateService, + staleTaskRecoveryService); var taskBridgeService = new TaskBridgeService( taskService, @@ -457,6 +465,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable activityRepository, notificationService, liveUpdateService, + staleTaskRecoveryService, taskService, taskBridgeService, agentService, @@ -539,6 +548,7 @@ file sealed class FakeDashboardService : IDashboardService public Task SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null)); public Task> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List()); public Task> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List()); + public Task GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok")); public Task DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored)); public Task CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored)); public Task GetAgentModelAsync(string agentId) => Task.FromResult(null); diff --git a/backend/Controllers/AgentsController.cs b/backend/Controllers/AgentsController.cs index 01442e3..5cb64fe 100644 --- a/backend/Controllers/AgentsController.cs +++ b/backend/Controllers/AgentsController.cs @@ -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 logger) : ControllerBase { [HttpGet] @@ -39,7 +42,25 @@ public class AgentsController( public async Task 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 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 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 + { + ["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 activity, + IReadOnlyList 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); } diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs index 60618ab..f345bf9 100644 --- a/backend/Controllers/DashboardController.cs +++ b/backend/Controllers/DashboardController.cs @@ -56,6 +56,10 @@ public class DashboardController( public async Task> GetQueue(CancellationToken ct) => await dashboardService.GetQueueAsync(ct); + [HttpGet("gateway")] + public async Task GetGateway(CancellationToken ct) + => await dashboardService.GetGatewayInfoAsync(ct); + [HttpDelete("queue/{id}")] public async Task DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct) { diff --git a/backend/Controllers/TasksController.cs b/backend/Controllers/TasksController.cs index db516a5..ecd96e2 100644 --- a/backend/Controllers/TasksController.cs +++ b/backend/Controllers/TasksController.cs @@ -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 GetAll(CancellationToken ct) @@ -27,6 +33,7 @@ public class TasksController(ITaskService taskService, IAgentService agentServic } [HttpGet("pending-approval")] + [Authorize(Roles = "owner")] public async Task 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 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 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(); + } } diff --git a/backend/Extensions/ServiceCollectionExtensions.cs b/backend/Extensions/ServiceCollectionExtensions.cs index 420e206..f2b0962 100644 --- a/backend/Extensions/ServiceCollectionExtensions.cs +++ b/backend/Extensions/ServiceCollectionExtensions.cs @@ -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 /// public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services) { + services.AddMcpServer() + .WithHttpTransport(options => options.Stateless = true) + .WithTools(); + + services.AddOptions() + .BindConfiguration(StaleTaskRecoveryOptions.SectionName); services.AddHttpContextAccessor(); services.AddSingleton(); services.AddTransient(); @@ -219,6 +226,8 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddHostedService(); // ── Backend Bridge (Agent-Command-Service) ── services.AddScoped(); diff --git a/backend/Helpers/PathSecurityHelper.cs b/backend/Helpers/PathSecurityHelper.cs index a199f54..8c001c0 100644 --- a/backend/Helpers/PathSecurityHelper.cs +++ b/backend/Helpers/PathSecurityHelper.cs @@ -26,10 +26,10 @@ public static class PathSecurityHelper return true; } - /// Validates config filename against path-traversal; must be alphanumeric .md. + /// Validates config filename against path-traversal; must be alphanumeric .md or .json. 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)$"); } } diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs index 2160a01..b2fdb44 100644 --- a/backend/Models/Dashboard.cs +++ b/backend/Models/Dashboard.cs @@ -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 ── diff --git a/backend/Nexus.Api.csproj b/backend/Nexus.Api.csproj index bc1a355..9a50b14 100644 --- a/backend/Nexus.Api.csproj +++ b/backend/Nexus.Api.csproj @@ -10,9 +10,9 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + - diff --git a/backend/Program.cs b/backend/Program.cs index e08a69f..d0ec0a2 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -22,5 +22,6 @@ await app.EnsureDatabaseAsync(); // --- Middleware Pipeline --- app.UseNexusPipeline(app.Environment); +app.MapMcp(); app.MapControllers(); app.Run(); diff --git a/backend/Repositories/ActivityRepository.cs b/backend/Repositories/ActivityRepository.cs index 77afda2..761d49e 100644 --- a/backend/Repositories/ActivityRepository.cs +++ b/backend/Repositories/ActivityRepository.cs @@ -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> 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> 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> 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 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; } } diff --git a/backend/Repositories/ITaskRepository.cs b/backend/Repositories/ITaskRepository.cs index 5c70482..8857dc9 100644 --- a/backend/Repositories/ITaskRepository.cs +++ b/backend/Repositories/ITaskRepository.cs @@ -8,6 +8,7 @@ public interface ITaskRepository ValueTask GetByIdAsync(Guid id, CancellationToken ct = default); Task> GetPendingApprovalAsync(CancellationToken ct = default); Task AddAsync(WorkTask task, CancellationToken ct = default); + Task 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 CountAsync(CancellationToken ct = default); diff --git a/backend/Repositories/TaskRepository.cs b/backend/Repositories/TaskRepository.cs index 9641530..fa64392 100644 --- a/backend/Repositories/TaskRepository.cs +++ b/backend/Repositories/TaskRepository.cs @@ -27,6 +27,41 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository return task; } + public async Task 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; diff --git a/backend/Services/AgentActivityText.cs b/backend/Services/AgentActivityText.cs new file mode 100644 index 0000000..e395377 --- /dev/null +++ b/backend/Services/AgentActivityText.cs @@ -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(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($@"(? ActorPatternCache = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/backend/Services/AgentConfigService.cs b/backend/Services/AgentConfigService.cs index 98517a6..051f528 100644 --- a/backend/Services/AgentConfigService.cs +++ b/backend/Services/AgentConfigService.cs @@ -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 SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default) + public async Task 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(); + + 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."); } diff --git a/backend/Services/DashboardService.cs b/backend/Services/DashboardService.cs index 2bbe5f3..bdbf387 100644 --- a/backend/Services/DashboardService.cs +++ b/backend/Services/DashboardService.cs @@ -112,6 +112,19 @@ public sealed class DashboardService( } } + public async Task 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 DeleteQueueItemAsync(string id, string? source, CancellationToken ct) { if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase)) diff --git a/backend/Services/IAgentConfigService.cs b/backend/Services/IAgentConfigService.cs index e5fd1fb..34efa26 100644 --- a/backend/Services/IAgentConfigService.cs +++ b/backend/Services/IAgentConfigService.cs @@ -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 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 GetConfigFiles(string agentId); Task GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default); - Task SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default); + Task SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default); } diff --git a/backend/Services/IDashboardService.cs b/backend/Services/IDashboardService.cs index 911dc96..543f675 100644 --- a/backend/Services/IDashboardService.cs +++ b/backend/Services/IDashboardService.cs @@ -16,6 +16,7 @@ public interface IDashboardService Task SendChatAsync(string agentId, string message); Task> GetMessagesAsync(string? sessionKey, int limit, int offset); Task> GetQueueAsync(CancellationToken ct); + Task GetGatewayInfoAsync(CancellationToken ct); Task DeleteQueueItemAsync(string id, string? source, CancellationToken ct); Task CycleQueuePriorityAsync(string id, CancellationToken ct); Task GetAgentModelAsync(string agentId); diff --git a/backend/Services/IOpenClawGatewayClient.cs b/backend/Services/IOpenClawGatewayClient.cs index a1378cb..945681c 100644 --- a/backend/Services/IOpenClawGatewayClient.cs +++ b/backend/Services/IOpenClawGatewayClient.cs @@ -12,6 +12,7 @@ public interface IOpenClawGatewayClient Task> GetAllAgentOperationsAsync(int limit = 30); Task SendChatMessageAsync(string agentId, string message); Task> GetQueueAsync(); + Task GetGatewayInfoAsync(CancellationToken ct = default); Task DeleteCronJobAsync(string id); Task GetAgentModelAsync(string agentId); Task SetAgentModelAsync(string agentId, string model); diff --git a/backend/Services/IStaleTaskRecoveryService.cs b/backend/Services/IStaleTaskRecoveryService.cs new file mode 100644 index 0000000..14344e0 --- /dev/null +++ b/backend/Services/IStaleTaskRecoveryService.cs @@ -0,0 +1,6 @@ +namespace Nexus.Api.Services; + +public interface IStaleTaskRecoveryService +{ + Task ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default); +} diff --git a/backend/Services/NexusMcpTools.cs b/backend/Services/NexusMcpTools.cs new file mode 100644 index 0000000..c35dc3d --- /dev/null +++ b/backend/Services/NexusMcpTools.cs @@ -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 logger) +{ + [McpServerTool(Name = "nexus_get_board")] + [Description("Get the full Nexus task board grouped by canonical states.")] + public async Task 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 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> 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> 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> 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> 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> 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> 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> 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> 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 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 ToResponse(TaskBridgeResult 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 ToActivityResponse( + TaskBridgeResult 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 +} diff --git a/backend/Services/OpenClawGatewayClient.cs b/backend/Services/OpenClawGatewayClient.cs index ed7d553..b46b92f 100644 --- a/backend/Services/OpenClawGatewayClient.cs +++ b/backend/Services/OpenClawGatewayClient.cs @@ -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(); 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(), "true", StringComparison.OrdinalIgnoreCase); // Fall back to status text - var statusText = status["status"]?.GetValue(); 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 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 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()?.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()?.Trim(); + var errorText = status["error"]?.GetValue()?.Trim() + ?? status["message"]?.GetValue()?.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()) + ?? NormalizeOptional(status["error"]?.GetValue()) + ?? NormalizeOptional(status["detail"]?.GetValue()); + + 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() - ?? status?["lastMessage"]?.GetValue(); + 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() + ?? status?["lastMessage"]?.GetValue() + ?? status?["updatedAt"]?.GetValue(); + 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"; + } } diff --git a/backend/Services/StaleTaskRecoveryBackgroundService.cs b/backend/Services/StaleTaskRecoveryBackgroundService.cs new file mode 100644 index 0000000..7d729ba --- /dev/null +++ b/backend/Services/StaleTaskRecoveryBackgroundService.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.Options; + +namespace Nexus.Api.Services; + +public sealed class StaleTaskRecoveryBackgroundService( + IServiceScopeFactory scopeFactory, + IOptionsMonitor optionsMonitor, + ILogger 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 RunRecoveryOnceAsync(CancellationToken ct = default) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var recoveryService = scope.ServiceProvider.GetRequiredService(); + return await recoveryService.ResetStaleInProgressTasksAsync(optionsMonitor.CurrentValue.GetStaleThreshold(), ct); + } +} diff --git a/backend/Services/StaleTaskRecoveryOptions.cs b/backend/Services/StaleTaskRecoveryOptions.cs new file mode 100644 index 0000000..bc3f517 --- /dev/null +++ b/backend/Services/StaleTaskRecoveryOptions.cs @@ -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)); +} diff --git a/backend/Services/StaleTaskRecoveryService.cs b/backend/Services/StaleTaskRecoveryService.cs new file mode 100644 index 0000000..59e486f --- /dev/null +++ b/backend/Services/StaleTaskRecoveryService.cs @@ -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 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> 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> GetLatestActivityByTaskIdAsync( + IEnumerable 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 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(); + var inProgress = new List(); + var review = new List(); + var blocked = new List(); + var done = new List(); + + 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 allTasks, + IEnumerable 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 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 + { + "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 + }; +} diff --git a/backend/Services/TaskService.cs b/backend/Services/TaskService.cs index 87cf138..9344362 100644 --- a/backend/Services/TaskService.cs +++ b/backend/Services/TaskService.cs @@ -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> 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 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 ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default) + => staleTaskRecoveryService.ResetStaleInProgressTasksAsync(staleThreshold, ct); public async Task> GetChildTasksAsync(Guid parentId, CancellationToken ct = default) { diff --git a/backend/appsettings.json b/backend/appsettings.json index e7cf4d6..2b55c25 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -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": "*" } diff --git a/docs/openclaw-task-board-flow.md b/docs/openclaw-task-board-flow.md index 0dabc65..8bab281 100644 --- a/docs/openclaw-task-board-flow.md +++ b/docs/openclaw-task-board-flow.md @@ -10,6 +10,7 @@ Diese Datei beschreibt den gewünschten und umgesetzten Arbeitsfluss zwischen: - **Sub-Agenten** als ausführende Spezialisten - **OpenClaw** als Agent-Runtime - **Nexus Task Board** als sichtbare Aufgabenquelle +- **MCP `/mcp`** als Agent Data Plane fuer Board-Operationen --- @@ -24,6 +25,8 @@ Das bedeutet: - **Child-Task** = konkrete Arbeitsaufgabe für einen Spezial-Agenten - **Board** = sichtbare Wahrheit für Aufgabenstatus und Ownership - **OpenClaw** = Ausführungspfad für Agentenarbeit +- **MCP** = bevorzugter Agentenpfad zu Nexus; `/api/bridge` bleibt + kompatible interne Fassade, `/api/dashboard` bleibt UI/Admin --- @@ -57,7 +60,7 @@ flowchart LR Iris -->|delegiert konkrete Arbeit| OC OC -->|führt Agenten-Task aus| Agents Iris -->|legt Child-Tasks an| Board - Agents -->|arbeiten gegen Child-Tasks| Board + Agents -->|MCP Tools /mcp| Board Agents -->|liefern Ergebnis / melden Blocker| Iris Iris -->|integriert Ergebnis| Board Board -->|Review für Bao| Bao @@ -89,6 +92,13 @@ flowchart LR - liefert Nachrichten, Status und Arbeitsergebnisse zurück - ersetzt nicht das Board als Aufgabenwahrheit +### MCP Agent Data Plane +- stellt `nexus_get_board`, `nexus_agent_overview`, Task-, Child-, + Activity-, Status-, Checkpoint- und Handoff-Tools bereit +- nutzt nur kanonische States: `Backlog`, `In progress`, `Blocked`, + `Done`, `Review` +- ist Fassade ueber `ITaskBridgeService`, keine zweite Board-Domaenenlogik + ### Nexus Task Board - ist die **sichtbare operative Quelle** für Aufgaben - zeigt Parent-Task, Child-Tasks, Ownership und Status @@ -315,6 +325,11 @@ Wenn Iris unsicher ist, ob sie eine Child-Task anlegen soll, gilt: - Agentenstatus und Boardstatus dürfen sich ergänzen, aber nicht widersprechen - Board-Spalten und API-State-Mapping müssen das Parent-/Child-Modell sauber abbilden - UI und Doku müssen dieselbe Sprache sprechen +- Mission-Control-Gateway-Daten bleiben read-only im Browser: Nexus proxyt Status, + Version und redigierte Activity; Gateway-Token und direkte Gateway-URLs bleiben + im Backend. +- Config-Writes sind Bao/Owner-only, legen vor dem Austausch ein `.bak` an und + schreiben einen `config_audit` Activity-Eintrag. --- diff --git a/frontend/src/components/ModuleView.vue b/frontend/src/components/ModuleView.vue index de90a0b..e79c6c6 100644 --- a/frontend/src/components/ModuleView.vue +++ b/frontend/src/components/ModuleView.vue @@ -4,7 +4,8 @@ import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, C import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types' import { TASK_STATES } from '../types' import { apiFetch } from '../services/api' -import { useOperationsStore } from '../stores/operations' +import { useAuthStore } from '../stores/auth' +import { useOperationsStore, type PendingApprovalTask } from '../stores/operations' const props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>() const emit = defineEmits<{ @@ -13,8 +14,13 @@ const emit = defineEmits<{ updateTaskState: [id: string, state: string] }>() const store = useOperationsStore() +const auth = useAuthStore() const agents = ref([]) const agentsLoading = ref(false) +const pendingApprovals = ref([]) +const pendingApprovalsLoading = ref(false) +const pendingApprovalsError = ref('') +const canModerateApprovals = computed(() => auth.user?.role === 'owner') async function loadAgents() { if (agentsLoading.value) return @@ -23,12 +29,43 @@ async function loadAgents() { agentsLoading.value = false } +async function loadPendingApprovals() { + if (!canModerateApprovals.value) { + pendingApprovals.value = [] + pendingApprovalsError.value = '' + return + } + + pendingApprovalsLoading.value = true + pendingApprovalsError.value = '' + try { + pendingApprovals.value = await store.fetchPendingApprovals() + } catch (e) { + pendingApprovalsError.value = e instanceof Error ? e.message : 'Failed to load pending approvals' + } finally { + pendingApprovalsLoading.value = false + } +} + onMounted(() => { if (props.view === 'Agents') loadAgents() + if (props.view === 'Task Board') void loadPendingApprovals() }) watch(() => props.view, (v) => { if (v === 'Agents') loadAgents() + if (v === 'Task Board') void loadPendingApprovals() +}) + +watch(canModerateApprovals, (value) => { + if (props.view !== 'Task Board') return + if (value) { + void loadPendingApprovals() + return + } + + pendingApprovals.value = [] + pendingApprovalsError.value = '' }) const newProject = ref('') @@ -51,6 +88,7 @@ async function handleApproveTask(id: string) { taskActionError.value = '' try { await store.approveTask(id) + pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id) } catch (e) { taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task' } finally { @@ -63,6 +101,7 @@ async function handleRejectTask(id: string) { taskActionError.value = '' try { await store.rejectTask(id) + pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id) } catch (e) { taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task' } finally { @@ -187,6 +226,31 @@ async function sendMessage() {
+
+
+
+ Owner approvals +

Pending approvals

+
+ {{ pendingApprovals.length }} +
+

Loading owner approval queue…

+

{{ pendingApprovalsError }}

+

No tasks are waiting for Bao approval.

+
+
+
+ {{ task.title }} +

{{ task.priority }} · {{ new Date(task.updatedAt).toLocaleString() }}

+
+
+ + +
+
+
+

{{ taskActionError }}

+
{{ column.name }}{{ column.items.length }}
@@ -214,7 +278,7 @@ async function sendMessage() {
{{ task.priority }}
-