feat: complete Nexus mission-control workflows

This commit is contained in:
2026-07-09 23:40:36 +02:00
parent 436ddfee0f
commit aaec3eb4ed
39 changed files with 3281 additions and 97 deletions
+62 -4
View File
@@ -174,6 +174,63 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
## API endpoints ## 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) ### Backend Bridge (Agent-zu-Backend, NICHT Frontend)
Der `/api/bridge/` Pfad ist ein strukturierter MCP-artiger Kommando-Adapter für die 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 | | `GET` | `/api/v1/tasks` | List all tasks |
| `POST` | `/api/v1/tasks` | Create task | | `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}` | Update task (title, priority, projectId) |
| `PATCH` | `/api/v1/tasks/{id}/state` | Update task state | | `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}/approve` | Owner-only approve task (in-progress -> done) |
| `POST` | `/api/v1/tasks/{id}/reject` | Reject task (in-progress backlog) | | `POST` | `/api/v1/tasks/{id}/reject` | Owner-only reject task (in-progress -> backlog) |
| `DELETE` | `/api/v1/tasks/{id}` | Delete task (only done/backlog states) | | `DELETE` | `/api/v1/tasks/{id}` | Delete task (only done/backlog states) |
### Agents ### 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` | List all agents |
| `GET` | `/api/v1/agents/{id}` | Agent detail (with sub-agents, identity) | | `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}/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 | | `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` | List agent config files (IDENTITY.md, SOUL.md, etc.) |
| `GET` | `/api/v1/agents/{id}/config/{fileName}` | Read config file content | | `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 ### Memory & Docs
+496
View File
@@ -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<AuthorizeAttribute>();
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<AuthorizeAttribute>()?.Roles);
Assert.Equal("owner", approve!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
Assert.Equal("owner", reject!.GetCustomAttribute<AuthorizeAttribute>()?.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<NexusDbContext>()
.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<NexusDbContext>()
.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<AgentsController>.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<IStatusCodeHttpResult>(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<HttpRequestMessage, HttpResponseMessage> responder,
string? requiredVersion = null,
string[]? agentIds = null)
{
var configValues = new Dictionary<string, string?>
{
["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<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(responder(request));
}
file sealed class FakeAgentConfigService(AgentConfigSaveAttempt attempt) : IAgentConfigService
{
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId) => [];
public Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
=> Task.FromResult<AgentConfigFileContent?>(null);
public Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
=> Task.FromResult(attempt);
}
file sealed class CapturingActivityRepository : IActivityRepository
{
public List<ActivityEvent> Added { get; } = [];
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
=> Task.FromResult((new List<ActivityEvent>(), 0));
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
{
Added.Add(activity);
return Task.FromResult(activity);
}
}
file sealed class FakeAgentService : IAgentService
{
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>([]);
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
=> Task.FromResult<AgentDetail?>(null);
public Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris", "bao", "programmer" });
}
file sealed class FakeAgentRuntime : Nexus.Api.Integrations.IAgentRuntime
{
public string Name => "fake";
public Task<Nexus.Api.Integrations.AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken)
=> Task.FromResult(new Nexus.Api.Integrations.AgentRuntimeStatus("fake", OperationalStatus.Online, TimeSpan.Zero, null));
public Task<Nexus.Api.Integrations.AgentChatResult> 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<DashboardStatus> GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0));
public Task<List<DashboardAgentInfo>> GetAgentsAsync() => Task.FromResult(new List<DashboardAgentInfo>());
public Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List<FeedEntry>());
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
public List<ModelOption> GetAvailableModels() => [];
}
+149
View File
@@ -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<McpServerToolAttribute>()
.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<NexusMcpTaskState>());
Assert.DoesNotContain("Delegated", Enum.GetNames<NexusMcpTaskState>());
}
[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<string, string>
{
["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<NexusMcpTools>.Instance);
}
+2
View File
@@ -94,6 +94,8 @@ internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) :
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException(); public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException(); public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task<bool> 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 UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException(); public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException(); public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
+348
View File
@@ -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<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
await using var provider = services.BuildServiceProvider();
var backgroundService = new StaleTaskRecoveryBackgroundService(
provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{
StaleHours = 4,
IntervalMinutes = 30
}),
NullLogger<StaleTaskRecoveryBackgroundService>.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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
fakeRecoveryService.OnCall = () => firstCall.TrySetResult(true);
var services = new ServiceCollection();
services.AddScoped<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
await using var provider = services.BuildServiceProvider();
var backgroundService = new StaleTaskRecoveryBackgroundService(
provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{
StaleHours = 2,
IntervalMinutes = 30
}),
NullLogger<StaleTaskRecoveryBackgroundService>.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<string, string?>
{
[$"{StaleTaskRecoveryOptions.SectionName}:StaleHours"] = "2",
[$"{StaleTaskRecoveryOptions.SectionName}:IntervalMinutes"] = "30"
})
.AddEnvironmentVariables()
.Build();
var options = configuration.GetSection(StaleTaskRecoveryOptions.SectionName).Get<StaleTaskRecoveryOptions>();
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<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> Task.FromResult(new List<WorkTask> { staleCandidate });
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> ValueTask.FromResult<WorkTask?>(id == currentTask.Id ? currentTask : null);
public Task<bool> 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<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
=> Task.FromResult(new List<WorkTask>());
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default)
=> Task.FromResult(task);
public Task DeleteAsync(WorkTask task, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<int> CountAsync(CancellationToken ct = default)
=> Task.FromResult(0);
public Task<int> CountByStateAsync(string state, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default)
=> Task.FromResult<WorkTask?>(null);
}
file sealed class FakeActivityRepository : IActivityRepository
{
public List<ActivityEvent> Added { get; } = [];
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> Task.FromResult(new List<ActivityEvent>());
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
=> Task.FromResult(new List<ActivityEvent>());
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type,
string? sort,
int page,
int pageSize,
CancellationToken ct = default)
=> Task.FromResult((new List<ActivityEvent>(), 0));
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
=> Task.FromResult(new List<ActivityEvent>());
public Task<ActivityEvent> 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<LiveUpdateSubscription> 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<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
CallCount++;
LastThreshold = staleThreshold;
OnCall?.Invoke();
return Task.FromResult(7);
}
}
file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
{
public T CurrentValue { get; private set; } = currentValue;
public T Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
+19 -9
View File
@@ -178,7 +178,7 @@ public sealed class TaskWorkflowTests
{ {
await using var fixture = await TaskWorkflowFixture.CreateAsync(); 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 ControllerContext = new ControllerContext
{ {
@@ -199,7 +199,7 @@ public sealed class TaskWorkflowTests
{ {
await using var fixture = await TaskWorkflowFixture.CreateAsync(); 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 ControllerContext = new ControllerContext
{ {
@@ -217,7 +217,7 @@ public sealed class TaskWorkflowTests
{ {
await using var fixture = await TaskWorkflowFixture.CreateAsync(); 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 ControllerContext = new ControllerContext
{ {
@@ -238,7 +238,7 @@ public sealed class TaskWorkflowTests
{ {
await using var fixture = await TaskWorkflowFixture.CreateAsync(); 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 ControllerContext = new ControllerContext
{ {
@@ -256,7 +256,7 @@ public sealed class TaskWorkflowTests
{ {
await using var fixture = await TaskWorkflowFixture.CreateAsync(); 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 ControllerContext = new ControllerContext
{ {
@@ -277,7 +277,7 @@ public sealed class TaskWorkflowTests
{ {
await using var fixture = await TaskWorkflowFixture.CreateAsync(); 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 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; private readonly NexusDbContext _db;
@@ -383,6 +383,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
IActivityRepository activityRepository, IActivityRepository activityRepository,
INotificationService notificationService, INotificationService notificationService,
ILiveUpdateService liveUpdateService, ILiveUpdateService liveUpdateService,
IStaleTaskRecoveryService staleTaskRecoveryService,
ITaskService taskService, ITaskService taskService,
ITaskBridgeService taskBridgeService, ITaskBridgeService taskBridgeService,
IAgentService agentService, IAgentService agentService,
@@ -394,6 +395,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
ActivityRepository = activityRepository; ActivityRepository = activityRepository;
NotificationService = notificationService; NotificationService = notificationService;
LiveUpdateService = liveUpdateService; LiveUpdateService = liveUpdateService;
StaleTaskRecoveryService = staleTaskRecoveryService;
TaskService = taskService; TaskService = taskService;
TaskBridgeService = taskBridgeService; TaskBridgeService = taskBridgeService;
AgentService = agentService; AgentService = agentService;
@@ -405,6 +407,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
public IActivityRepository ActivityRepository { get; } public IActivityRepository ActivityRepository { get; }
public INotificationService NotificationService { get; } public INotificationService NotificationService { get; }
public ILiveUpdateService LiveUpdateService { get; } public ILiveUpdateService LiveUpdateService { get; }
public IStaleTaskRecoveryService StaleTaskRecoveryService { get; }
public ITaskService TaskService { get; } public ITaskService TaskService { get; }
public ITaskBridgeService TaskBridgeService { get; } public ITaskBridgeService TaskBridgeService { get; }
public IAgentService AgentService { get; } public IAgentService AgentService { get; }
@@ -430,10 +433,14 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
var agentService = new AgentService(configuration, new FakeRuntime()); var agentService = new AgentService(configuration, new FakeRuntime());
var liveUpdateService = new LiveUpdateService(); var liveUpdateService = new LiveUpdateService();
var activityRepository = new ActivityRepository(db); var activityRepository = new ActivityRepository(db, liveUpdateService);
var taskRepository = new TaskRepository(db); var taskRepository = new TaskRepository(db);
var notificationService = new NotificationService(db, liveUpdateService); var notificationService = new NotificationService(db, liveUpdateService);
var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") }; var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") };
var staleTaskRecoveryService = new StaleTaskRecoveryService(
taskRepository,
activityRepository,
liveUpdateService);
var taskService = new TaskService( var taskService = new TaskService(
taskRepository, taskRepository,
@@ -441,7 +448,8 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
notificationService, notificationService,
agentService, agentService,
httpContextAccessor, httpContextAccessor,
liveUpdateService); liveUpdateService,
staleTaskRecoveryService);
var taskBridgeService = new TaskBridgeService( var taskBridgeService = new TaskBridgeService(
taskService, taskService,
@@ -457,6 +465,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
activityRepository, activityRepository,
notificationService, notificationService,
liveUpdateService, liveUpdateService,
staleTaskRecoveryService,
taskService, taskService,
taskBridgeService, taskBridgeService,
agentService, agentService,
@@ -539,6 +548,7 @@ file sealed class FakeDashboardService : IDashboardService
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null)); public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>()); public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>()); public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored)); public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored)); public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null); public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
+145 -8
View File
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using System.Security.Claims;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Integrations; using Nexus.Api.Integrations;
using Nexus.Api.Repositories; using Nexus.Api.Repositories;
@@ -14,6 +16,7 @@ public class AgentsController(
IAgentRuntime runtime, IAgentRuntime runtime,
IActivityRepository activityRepo, IActivityRepository activityRepo,
IAgentConfigService agentConfigService, IAgentConfigService agentConfigService,
IDashboardService dashboardService,
ILogger<AgentsController> logger) : ControllerBase ILogger<AgentsController> logger) : ControllerBase
{ {
[HttpGet] [HttpGet]
@@ -39,7 +42,25 @@ public class AgentsController(
public async Task<IResult> GetAgentActivity(string id, CancellationToken ct) public async Task<IResult> GetAgentActivity(string id, CancellationToken ct)
{ {
var items = await activityRepo.GetByAgentAsync(id, 50, ct); var items = await activityRepo.GetByAgentAsync(id, 50, ct);
return Results.Ok(items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })); var activity = items
.Select(x => new AgentActivityResponse(x.Id, x.Type, x.Message, x.CreatedAt, "activity"))
.ToList();
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 10);
foreach (var entry in gatewayEntries)
activity.Add(new AgentActivityResponse(null, "thinking", entry.Text, entry.Timestamp, entry.Source, entry.Time));
return Results.Ok(activity
.OrderByDescending(x => x.At)
.Take(50));
}
[HttpGet("{id}/summary")]
public async Task<IResult> GetAgentSummary(string id, CancellationToken ct)
{
var recent = await activityRepo.GetByAgentAsync(id, 25, ct);
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 8);
return Results.Ok(AgentSummaryBuilder.Build(recent, gatewayEntries, DateTimeOffset.UtcNow));
} }
[HttpPost("{id}/command")] [HttpPost("{id}/command")]
@@ -84,20 +105,48 @@ public class AgentsController(
} }
[HttpPut("{id}/config/{fileName}")] [HttpPut("{id}/config/{fileName}")]
[Authorize(Roles = "owner")]
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct) public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
{ {
if (request.Content is null) if (request.Content is null)
return Results.BadRequest(new { error = "Content is required." }); 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 try
{ {
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct); var attempt = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
return result is null var caller = DescribeCaller(HttpContext.User);
? Results.BadRequest(new { error = "Invalid filename or path." })
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt }); if (attempt.Failure is not null)
{
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save rejected agent={id} file={fileName} caller={caller} validation={attempt.Failure.Validation.Status} backup={attempt.Failure.Backup.Status} reload={attempt.Failure.ReloadCheck.Status} code={attempt.Failure.Code}",
}, ct);
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["content"] = attempt.Failure.Validation.Errors.ToArray()
});
}
var result = attempt.SaveResult!;
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save agent={id} file={fileName} caller={caller} validation={result.Validation.Status} backup={result.Backup.Status} reload={result.ReloadCheck.Status}",
}, ct);
return Results.Ok(new
{
result.FileName,
result.Size,
result.ModifiedAt,
result.Validation,
result.Backup,
ReloadCheck = result.ReloadCheck
});
} }
catch (UnauthorizedAccessException ex) catch (UnauthorizedAccessException ex)
{ {
@@ -116,4 +165,92 @@ public class AgentsController(
statusCode: StatusCodes.Status500InternalServerError); statusCode: StatusCodes.Status500InternalServerError);
} }
} }
private static string DescribeCaller(ClaimsPrincipal user)
{
var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst(ClaimTypes.Email)?.Value
?? user.Identity?.Name
?? "unknown";
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
return $"{role}:{subject}".ToLowerInvariant();
}
}
public sealed record AgentActivityResponse(
long? Id,
string Type,
string Message,
DateTimeOffset At,
string Source,
string? RelativeTime = null
);
public sealed record AgentSummaryResponse(
AgentSummaryItemResponse Now,
AgentSummaryItemResponse Today,
DateTimeOffset GeneratedAt
);
public sealed record AgentSummaryItemResponse(
string Text,
string Source,
DateTimeOffset? Timestamp
);
public static class AgentSummaryBuilder
{
public static AgentSummaryResponse Build(
IReadOnlyList<Nexus.Api.Data.ActivityEvent> activity,
IReadOnlyList<Nexus.Api.Models.AgentActivityEntry> gatewayEntries,
DateTimeOffset nowUtc)
{
var points = activity
.Select(entry => new SummaryPoint(entry.Message, entry.CreatedAt, "nexus-activity"))
.Concat(gatewayEntries.Select(entry => new SummaryPoint(entry.Text, entry.Timestamp, entry.Source)))
.Select(point => point with { Text = AgentActivityText.RedactForDisplay(point.Text) })
.Where(point => !string.IsNullOrWhiteSpace(point.Text))
.OrderByDescending(point => point.Timestamp)
.ToList();
var current = points.FirstOrDefault();
var now = current is null
? new AgentSummaryItemResponse("Keine aktuelle Aktivitaet.", "none", null)
: new AgentSummaryItemResponse(current.Text, current.Source, current.Timestamp);
var windowStart = nowUtc.AddHours(-24);
var todayPoints = points
.Where(point => point.Timestamp >= windowStart)
.ToList();
AgentSummaryItemResponse today;
if (todayPoints.Count == 0)
{
today = new AgentSummaryItemResponse("Heute keine verwertbaren Checkpoints.", "none", null);
}
else
{
var snippets = todayPoints
.Select(point => point.Text)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(3)
.ToList();
var extraCount = Math.Max(0, todayPoints.Count - snippets.Count);
var text = $"Letzte 24h: {string.Join(" | ", snippets)}";
if (extraCount > 0)
text += $" (+{extraCount} weitere)";
var source = todayPoints.Select(point => point.Source).Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1
? todayPoints[0].Source
: "derived-mixed";
today = new AgentSummaryItemResponse(text, source, todayPoints[0].Timestamp);
}
return new AgentSummaryResponse(now, today, nowUtc);
}
private sealed record SummaryPoint(string Text, DateTimeOffset Timestamp, string Source);
} }
@@ -56,6 +56,10 @@ public class DashboardController(
public async Task<List<QueueItem>> GetQueue(CancellationToken ct) public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
=> await dashboardService.GetQueueAsync(ct); => await dashboardService.GetQueueAsync(ct);
[HttpGet("gateway")]
public async Task<GatewayRuntimeInfo> GetGateway(CancellationToken ct)
=> await dashboardService.GetGatewayInfoAsync(ct);
[HttpDelete("queue/{id}")] [HttpDelete("queue/{id}")]
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct) public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
{ {
+38 -1
View File
@@ -1,8 +1,10 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using Nexus.Api.Data; using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Models; using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
@@ -10,7 +12,11 @@ namespace Nexus.Api.Controllers;
[Authorize] [Authorize]
[ApiController] [ApiController]
[Route("api/v1/tasks")] [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] [HttpGet]
public async Task<IResult> GetAll(CancellationToken ct) public async Task<IResult> GetAll(CancellationToken ct)
@@ -27,6 +33,7 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
} }
[HttpGet("pending-approval")] [HttpGet("pending-approval")]
[Authorize(Roles = "owner")]
public async Task<IResult> GetPendingApproval(CancellationToken ct) public async Task<IResult> GetPendingApproval(CancellationToken ct)
{ {
var pending = await taskService.GetPendingApprovalAsync(ct); var pending = await taskService.GetPendingApprovalAsync(ct);
@@ -34,9 +41,11 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
} }
[HttpPost("{id:guid}/approve")] [HttpPost("{id:guid}/approve")]
[Authorize(Roles = "owner")]
public async Task<IResult> Approve(Guid id, CancellationToken ct) public async Task<IResult> Approve(Guid id, CancellationToken ct)
{ {
var result = await taskService.ApproveAsync(id, ct); var result = await taskService.ApproveAsync(id, ct);
await WriteApprovalAuditAsync(id, "approve", result.Outcome, result.Task?.State, ct);
return result.Outcome switch return result.Outcome switch
{ {
TaskOperationOutcome.NotFound => Results.NotFound(), TaskOperationOutcome.NotFound => Results.NotFound(),
@@ -49,9 +58,11 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
} }
[HttpPost("{id:guid}/reject")] [HttpPost("{id:guid}/reject")]
[Authorize(Roles = "owner")]
public async Task<IResult> Reject(Guid id, CancellationToken ct) public async Task<IResult> Reject(Guid id, CancellationToken ct)
{ {
var result = await taskService.RejectAsync(id, ct); var result = await taskService.RejectAsync(id, ct);
await WriteApprovalAuditAsync(id, "reject", result.Outcome, result.Task?.State, ct);
return result.Outcome switch return result.Outcome switch
{ {
TaskOperationOutcome.NotFound => Results.NotFound(), TaskOperationOutcome.NotFound => Results.NotFound(),
@@ -160,4 +171,30 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
var count = await taskService.ResetStaleAsync(request.StaleHours, ct); var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
return Results.Ok(new ResetStaleResponse(count)); return Results.Ok(new ResetStaleResponse(count));
} }
private async Task WriteApprovalAuditAsync(
Guid taskId,
string action,
TaskOperationOutcome outcome,
string? state,
CancellationToken ct)
{
await activityRepository.AddAsync(new ActivityEvent
{
Type = "task_approval_audit",
Message = $"Task approval task={taskId} action={action} caller={DescribeCaller(HttpContext.User)} outcome={outcome} checkpoint={(state ?? "none")}",
TaskId = taskId
}, ct);
}
private static string DescribeCaller(ClaimsPrincipal user)
{
var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst(ClaimTypes.Email)?.Value
?? user.Identity?.Name
?? "unknown";
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
return $"{role}:{subject}".ToLowerInvariant();
}
} }
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore;
using Nexus.Api.Data; using Nexus.Api.Data;
using Nexus.Api.Integrations; using Nexus.Api.Integrations;
using Nexus.Api.RateLimiting; using Nexus.Api.RateLimiting;
@@ -202,6 +203,12 @@ public static class ServiceCollectionExtensions
/// </summary> /// </summary>
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services) public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
{ {
services.AddMcpServer()
.WithHttpTransport(options => options.Stateless = true)
.WithTools<NexusMcpTools>();
services.AddOptions<StaleTaskRecoveryOptions>()
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<LoginAttemptTracker>(); services.AddSingleton<LoginAttemptTracker>();
services.AddTransient<ModelRoutingService>(); services.AddTransient<ModelRoutingService>();
@@ -219,6 +226,8 @@ public static class ServiceCollectionExtensions
services.AddSingleton<ILiveUpdateService, LiveUpdateService>(); services.AddSingleton<ILiveUpdateService, LiveUpdateService>();
services.AddScoped<INotificationService, NotificationService>(); services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<ICalendarService, CalendarService>(); services.AddScoped<ICalendarService, CalendarService>();
services.AddScoped<IStaleTaskRecoveryService, StaleTaskRecoveryService>();
services.AddHostedService<StaleTaskRecoveryBackgroundService>();
// ── Backend Bridge (Agent-Command-Service) ── // ── Backend Bridge (Agent-Command-Service) ──
services.AddScoped<ITaskBridgeService, TaskBridgeService>(); services.AddScoped<ITaskBridgeService, TaskBridgeService>();
+2 -2
View File
@@ -26,10 +26,10 @@ public static class PathSecurityHelper
return true; return true;
} }
/// <summary>Validates config filename against path-traversal; must be alphanumeric .md.</summary> /// <summary>Validates config filename against path-traversal; must be alphanumeric .md or .json.</summary>
public static bool IsValidConfigFileName(string fileName) public static bool IsValidConfigFileName(string fileName)
{ {
if (string.IsNullOrWhiteSpace(fileName)) return false; 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)$");
} }
} }
+18 -1
View File
@@ -14,6 +14,8 @@ public sealed record DashboardAgentInfo(
string? Goal = null, string? Goal = null,
string RoleBadge = "badge-slate", string RoleBadge = "badge-slate",
string StatusLabel = "Bereit", string StatusLabel = "Bereit",
string StatusKind = "ready",
string? StatusDetail = null,
string? Elapsed = null, string? Elapsed = null,
string? Think = null, string? Think = null,
string? Next = null string? Next = null
@@ -136,7 +138,22 @@ public sealed record UpdateDashboardTaskStatusRequest(
public sealed record AgentActivityEntry( public sealed record AgentActivityEntry(
string Time, 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 ── // ── Task Board DTOs ──
+1 -1
View File
@@ -10,9 +10,9 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+1
View File
@@ -22,5 +22,6 @@ await app.EnsureDatabaseAsync();
// --- Middleware Pipeline --- // --- Middleware Pipeline ---
app.UseNexusPipeline(app.Environment); app.UseNexusPipeline(app.Environment);
app.MapMcp();
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();
+23 -5
View File
@@ -3,7 +3,7 @@ using Nexus.Api.Data;
namespace Nexus.Api.Repositories; namespace Nexus.Api.Repositories;
public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository public sealed class ActivityRepository(NexusDbContext db, Nexus.Api.Services.ILiveUpdateService liveUpdates) : IActivityRepository
{ {
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default) public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct); => 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); return (items, totalCount);
} }
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) public async Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
=> db.Activity.AsNoTracking() {
.Where(x => x.Message.Contains(agentId, StringComparison.OrdinalIgnoreCase) || x.Type == "agent") var candidateCount = Math.Max(take * 8, 100);
var recent = await db.Activity.AsNoTracking()
.OrderByDescending(x => x.CreatedAt) .OrderByDescending(x => x.CreatedAt)
.Take(take) .Take(candidateCount)
.ToListAsync(ct); .ToListAsync(ct);
return recent
.Where(x => Nexus.Api.Services.AgentActivityText.MatchesAgent(x.Message, agentId))
.Take(take)
.ToList();
}
public async Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default) public async Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
{ {
var agentIds = Nexus.Api.Services.AgentActivityText.ExtractAgentIds(activity.Message);
activity.Message = Nexus.Api.Services.AgentActivityText.RedactForDisplay(activity.Message);
db.Activity.Add(activity); db.Activity.Add(activity);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
liveUpdates.Publish("activity.created", new
{
activity.Id,
activity.Type,
activity.Message,
activity.TaskId,
activity.CreatedAt,
agentIds
}, "activity");
return activity; return activity;
} }
} }
+1
View File
@@ -8,6 +8,7 @@ public interface ITaskRepository
ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default); ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default); Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default); Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default);
Task<bool> TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default);
Task UpdateAsync(WorkTask task, CancellationToken ct = default); Task UpdateAsync(WorkTask task, CancellationToken ct = default);
Task DeleteAsync(WorkTask task, CancellationToken ct = default); Task DeleteAsync(WorkTask task, CancellationToken ct = default);
Task<int> CountAsync(CancellationToken ct = default); Task<int> CountAsync(CancellationToken ct = default);
+35
View File
@@ -27,6 +27,41 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
return task; return task;
} }
public async Task<bool> TryResetStaleInProgressToBacklogAsync(
Guid id,
DateTimeOffset staleBefore,
DateTimeOffset updatedAt,
CancellationToken ct = default)
{
if (!db.Database.IsRelational())
{
var task = await db.Tasks
.FirstOrDefaultAsync(task => task.Id == id
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
&& task.UpdatedAt < staleBefore, ct);
if (task is null)
{
return false;
}
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
task.UpdatedAt = updatedAt;
await db.SaveChangesAsync(ct);
return true;
}
var affectedRows = await db.Tasks
.Where(task => task.Id == id
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
&& task.UpdatedAt < staleBefore)
.ExecuteUpdateAsync(setters => setters
.SetProperty(task => task.State, TaskStateHelper.ToStateString(TaskState.Backlog))
.SetProperty(task => task.UpdatedAt, updatedAt), ct);
return affectedRows > 0;
}
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default) public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
{ {
task.UpdatedAt = DateTimeOffset.UtcNow; task.UpdatedAt = DateTimeOffset.UtcNow;
+89
View File
@@ -0,0 +1,89 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
namespace Nexus.Api.Services;
public static class AgentActivityText
{
private static readonly (Regex Pattern, string Replacement)[] InlineRedactions =
[
(new Regex(@"(?i)(authorization\s*:\s*bearer)\s+\S+", RegexOptions.CultureInvariant), "$1 [redacted]"),
(new Regex(@"(?i)(x-nexus-api-key\s*:\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(api[_-]?key\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(token\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(password\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(secret\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(jwt\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(private[_-]?key\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]")
];
private static readonly Regex[] ResidualSensitivePatterns =
[
new(@"(?i)bearer\s+(?!\[redacted\])\S+", RegexOptions.CultureInvariant),
new(@"(?i)x-nexus-api-key\s*:\s*(?!\[redacted\])\S+", RegexOptions.CultureInvariant),
new(@"(?i)private[_-]?key\s*[:=]\s*(?!\[redacted\])\S+", RegexOptions.CultureInvariant)
];
private static readonly string[] KnownActorIds =
[
.. AgentIdentityCatalog.DefaultConfiguredAgentIds,
"bao",
"nexus-system"
];
public static string RedactForDisplay(string? content)
{
if (string.IsNullOrWhiteSpace(content))
return content ?? string.Empty;
var lines = content.Split('\n');
for (var i = 0; i < lines.Length; i++)
{
var sanitized = lines[i];
foreach (var (pattern, replacement) in InlineRedactions)
{
sanitized = pattern.Replace(sanitized, replacement);
}
if (ResidualSensitivePatterns.Any(pattern => pattern.IsMatch(sanitized)))
sanitized = "[redacted sensitive line]";
lines[i] = sanitized;
}
return string.Join('\n', lines).Trim();
}
public static bool MatchesAgent(string? content, string agentId)
{
if (string.IsNullOrWhiteSpace(agentId))
return false;
var normalized = agentId.Trim().ToLowerInvariant();
return ExtractAgentIds(content).Contains(normalized, StringComparer.OrdinalIgnoreCase);
}
public static string[] ExtractAgentIds(string? content)
{
if (string.IsNullOrWhiteSpace(content))
return [];
var matches = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var actorId in KnownActorIds)
{
if (BuildActorRegex(actorId).IsMatch(content))
matches.Add(actorId);
}
return matches
.Select(actorId => actorId.ToLowerInvariant())
.OrderBy(actorId => actorId, StringComparer.Ordinal)
.ToArray();
}
private static Regex BuildActorRegex(string actorId)
=> ActorPatternCache.GetOrAdd(actorId, static key =>
new Regex($@"(?<![a-z0-9]){Regex.Escape(key)}(?![a-z0-9])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant));
private static readonly ConcurrentDictionary<string, Regex> ActorPatternCache = new(StringComparer.OrdinalIgnoreCase);
}
+88 -5
View File
@@ -1,3 +1,4 @@
using System.Text.Json;
using Nexus.Api.Helpers; using Nexus.Api.Helpers;
namespace Nexus.Api.Services; namespace Nexus.Api.Services;
@@ -27,6 +28,8 @@ public sealed class AgentConfigService : IAgentConfigService
{ {
if (!PathSecurityHelper.IsValidConfigFileName(fileName)) if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return null; return null;
if (!AllowedFiles.Contains(fileName))
return null;
var workspacePath = $"/mnt/workspace-{agentId}"; var workspacePath = $"/mnt/workspace-{agentId}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath)) 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); return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
} }
public async Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default) public async Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
{ {
if (!PathSecurityHelper.IsValidConfigFileName(fileName)) var fileKind = DetermineFileKind(fileName);
return null; 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}"; 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)) 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 tempPath = safePath + ".tmp";
var backupPath = safePath + ".bak";
var backupCreated = false;
try try
{ {
if (File.Exists(safePath))
{
File.Copy(safePath, backupPath, overwrite: true);
backupCreated = true;
}
await File.WriteAllTextAsync(tempPath, content, ct); await File.WriteAllTextAsync(tempPath, content, ct);
File.Move(tempPath, safePath!, overwrite: true); File.Move(tempPath, safePath!, overwrite: true);
} }
@@ -59,6 +88,60 @@ public sealed class AgentConfigService : IAgentConfigService
} }
var fi = new FileInfo(safePath!); var fi = new FileInfo(safePath!);
return new AgentConfigFileSaveResult(fileName, fi.Length, fi.LastWriteTimeUtc); return new AgentConfigSaveAttempt(
new AgentConfigFileSaveResult(
fileName,
fi.Length,
fi.LastWriteTimeUtc,
new AgentConfigValidationResult("passed", fileKind, []),
new AgentConfigBackupResult(backupCreated ? "created" : "not_applicable", backupCreated),
CreateReloadCheck()),
null);
}
private static AgentConfigValidationResult Validate(string fileName, string content, string fileKind)
{
var errors = new List<string>();
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
errors.Add("Filename is invalid.");
else if (!AllowedFiles.Contains(fileName))
errors.Add("File is not allowed for Mission Control editing.");
if (content.IndexOf('\0') >= 0)
errors.Add("Content contains null bytes.");
if (content.Length > IAgentConfigService.MaxConfigFileBytes)
errors.Add($"Content exceeds maximum size of {IAgentConfigService.MaxConfigFileBytes / 1024}KB.");
if (string.Equals(fileKind, "json", StringComparison.OrdinalIgnoreCase))
{
try
{
JsonDocument.Parse(content);
}
catch (JsonException ex)
{
errors.Add($"JSON validation failed: {ex.Message}");
} }
} }
return new AgentConfigValidationResult(errors.Count == 0 ? "passed" : "failed", fileKind, errors);
}
private static string DetermineFileKind(string fileName)
{
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
return "json";
if (fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
return "markdown";
return "text";
}
private static AgentConfigReloadCheckResult CreateReloadCheck()
=> new(
"not_supported",
"Mission Control verified the file write locally, but agent hot reload is not available for workspace config files.");
}
+13
View File
@@ -112,6 +112,19 @@ public sealed class DashboardService(
} }
} }
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct)
{
try
{
return await gateway.GetGatewayInfoAsync(ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Gateway info fetch failed");
return new GatewayRuntimeInfo(false, "unknown", null, null, false, false, "error", DateTimeOffset.UtcNow, "Gateway nicht erreichbar", "Gateway nicht erreichbar");
}
}
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
{ {
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase)) if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
+29 -2
View File
@@ -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 AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
public sealed record AgentConfigFileSaveResult(string FileName, long Size, DateTime ModifiedAt); public sealed record AgentConfigValidationResult(string Status, string FileKind, IReadOnlyList<string> Errors);
public sealed record AgentConfigBackupResult(string Status, bool BackupCreated);
public sealed record AgentConfigReloadCheckResult(string Status, string Message);
public sealed record AgentConfigFileSaveResult(
string FileName,
long Size,
DateTime ModifiedAt,
AgentConfigValidationResult Validation,
AgentConfigBackupResult Backup,
AgentConfigReloadCheckResult ReloadCheck
);
public sealed record AgentConfigSaveFailure(
string Code,
AgentConfigValidationResult Validation,
AgentConfigBackupResult Backup,
AgentConfigReloadCheckResult ReloadCheck
);
public sealed record AgentConfigSaveAttempt(
AgentConfigFileSaveResult? SaveResult,
AgentConfigSaveFailure? Failure
);
public interface IAgentConfigService public interface IAgentConfigService
{ {
const int MaxConfigFileBytes = 500 * 1024;
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId); IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default); Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default); Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
} }
+1
View File
@@ -16,6 +16,7 @@ public interface IDashboardService
Task<ChatResponse> SendChatAsync(string agentId, string message); Task<ChatResponse> SendChatAsync(string agentId, string message);
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset); Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct); Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct);
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct); Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct); Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId); Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
@@ -12,6 +12,7 @@ public interface IOpenClawGatewayClient
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30); Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
Task<ChatResponse> SendChatMessageAsync(string agentId, string message); Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
Task<List<QueueItem>> GetQueueAsync(); Task<List<QueueItem>> GetQueueAsync();
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default);
Task<bool> DeleteCronJobAsync(string id); Task<bool> DeleteCronJobAsync(string id);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId); Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
Task<bool> SetAgentModelAsync(string agentId, string model); Task<bool> SetAgentModelAsync(string agentId, string model);
@@ -0,0 +1,6 @@
namespace Nexus.Api.Services;
public interface IStaleTaskRecoveryService
{
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
}
+232
View File
@@ -0,0 +1,232 @@
using System.ComponentModel;
using System.Security.Claims;
using ModelContextProtocol.Server;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
[McpServerToolType]
public sealed class NexusMcpTools(
ITaskBridgeService bridge,
IAgentService agentService,
IHttpContextAccessor httpContextAccessor,
IConfiguration configuration,
ILogger<NexusMcpTools> logger)
{
[McpServerTool(Name = "nexus_get_board")]
[Description("Get the full Nexus task board grouped by canonical states.")]
public async Task<BoardResponse> GetBoard(CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetBoardAsync(ct);
}
[McpServerTool(Name = "nexus_agent_overview")]
[Description("Get agent workflow overview, including waiting and stale task groups.")]
public async Task<AgentWorkflowOverview> GetAgentOverview(
[Description("Stale threshold in hours. Defaults to 2.")]
int staleHours = 2,
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetAgentOverviewAsync(TimeSpan.FromHours(Math.Max(1, staleHours)), ct);
}
[McpServerTool(Name = "nexus_get_task")]
[Description("Get one Nexus task by ID.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> GetTask(Guid taskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return ToResponse(await bridge.GetTaskAsync(taskId, ct), "nexus_get_task");
}
[McpServerTool(Name = "nexus_get_children")]
[Description("Get child tasks for a Nexus parent task.")]
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildren(Guid parentTaskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetChildTasksAsync(parentTaskId, ct);
}
[McpServerTool(Name = "nexus_get_activity")]
[Description("Get activity entries for a Nexus task.")]
public async Task<IReadOnlyList<ActivityEntryDto>> GetActivity(Guid taskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var activity = await bridge.GetTaskActivityAsync(taskId, ct);
return activity.Select(entry => new ActivityEntryDto(entry.Id, entry.Type, entry.Message, entry.CreatedAt)).ToList();
}
[McpServerTool(Name = "nexus_create_task")]
[Description("Create a top-level Nexus task.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateTask(
string title,
string? detail = null,
string? priority = "Normal",
string? assignedTo = null,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.CreateTaskAsync(
title: title,
detail: detail,
source: ResolveSource(caller),
priority: priority,
assignedTo: assignedTo ?? caller,
ct: ct);
return ToResponse(result, "nexus_create_task");
}
[McpServerTool(Name = "nexus_create_child_task")]
[Description("Create a visible child task under a Nexus parent task for delegation.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateChildTask(
Guid parentTaskId,
string title,
string? detail = null,
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.CreateChildTaskAsync(
parentTaskId: parentTaskId,
title: title,
detail: detail,
source: ResolveSource(caller),
priority: priority,
assignedTo: assignedTo,
expectedFrom: expectedFrom ?? assignedTo,
startsInProgress: startsInProgress,
ct: ct);
return ToResponse(result, "nexus_create_child_task");
}
[McpServerTool(Name = "nexus_update_status")]
[Description("Update a Nexus task status. The schema only exposes canonical task states.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> UpdateStatus(
Guid taskId,
NexusMcpTaskState state,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.UpdateStatusAsync(taskId, ToStateString(state), caller, ct);
return ToResponse(result, "nexus_update_status");
}
[McpServerTool(Name = "nexus_append_activity")]
[Description("Append an activity/checkpoint entry to a Nexus task.")]
public async Task<TaskBridgeCommandResponse<ActivityEntryDto>> AppendActivity(
Guid taskId,
string message,
string? type = "comment",
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var result = await bridge.AppendActivityAsync(taskId, message, type, ct);
return ToActivityResponse(result, "nexus_append_activity");
}
[McpServerTool(Name = "nexus_handoff")]
[Description("Mark a task handoff to another known agent and append handoff activity.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> Handoff(
Guid taskId,
string targetAgent,
string? note = null,
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var result = await bridge.HandoffAsync(taskId, targetAgent, note, ct);
return ToResponse(result, "nexus_handoff");
}
private async Task<string> ResolveCallerAsync(CancellationToken ct)
{
var context = httpContextAccessor.HttpContext
?? throw new UnauthorizedAccessException("MCP request context is not available.");
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
var agentHeader = context.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
{
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
if (allowedActorIds.Contains(normalizedHeader))
return normalizedHeader;
logger.LogWarning("MCP: ignoring unknown X-Agent-Id '{AgentId}' from {Ip}",
normalizedHeader,
context.Connection.RemoteIpAddress);
}
if (context.User.Identity?.IsAuthenticated == true)
{
var normalizedClaim = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim))
return normalizedClaim;
if (context.User.IsInRole("owner") || context.User.IsInRole("admin"))
return "bao";
}
if (RequestAuthorizationHelper.IsAuthenticatedService(context, configuration) &&
allowedActorIds.Contains("nexus-system"))
return "nexus-system";
logger.LogWarning("MCP: unauthenticated request rejected from {Ip}", context.Connection.RemoteIpAddress);
throw new UnauthorizedAccessException("MCP tools require X-Nexus-Api-Key or a recognized X-Agent-Id.");
}
private static string ResolveSource(string agentId) => agentId switch
{
"bao" or "nexus-system" => "bao",
_ => agentId
};
private static string ToStateString(NexusMcpTaskState state) => state switch
{
NexusMcpTaskState.Backlog => TaskStateHelper.ToStateString(TaskState.Backlog),
NexusMcpTaskState.InProgress => TaskStateHelper.ToStateString(TaskState.InProgress),
NexusMcpTaskState.Blocked => TaskStateHelper.ToStateString(TaskState.Blocked),
NexusMcpTaskState.Done => TaskStateHelper.ToStateString(TaskState.Done),
NexusMcpTaskState.Review => TaskStateHelper.ToStateString(TaskState.Review),
_ => throw new InvalidEnumArgumentException(nameof(state), (int)state, typeof(NexusMcpTaskState))
};
private static TaskBridgeCommandResponse<T> ToResponse<T>(TaskBridgeResult<T> result, string command) where T : class
=> new()
{
Ok = result.Outcome == TaskBridgeOutcome.Success,
Command = command,
Data = result.Outcome == TaskBridgeOutcome.Success ? result.Data : null,
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
};
private static TaskBridgeCommandResponse<ActivityEntryDto> ToActivityResponse(
TaskBridgeResult<ActivityEvent> result,
string command)
=> new()
{
Ok = result.Outcome == TaskBridgeOutcome.Success,
Command = command,
Data = result.Data is null
? null
: new ActivityEntryDto(result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt),
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
};
}
public enum NexusMcpTaskState
{
Backlog,
InProgress,
Blocked,
Done,
Review
}
+245 -15
View File
@@ -8,6 +8,14 @@ namespace Nexus.Api.Services;
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient 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() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
@@ -139,6 +147,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
// 3. Extract activity from session_status // 3. Extract activity from session_status
var isActive = false; var isActive = false;
string? currentTask = null; string? currentTask = null;
var statusText = status?["status"]?.GetValue<string>();
if (status is not null) if (status is not null)
{ {
// Check explicit isActive field // Check explicit isActive field
@@ -149,7 +158,6 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase); isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase);
// Fall back to status text // Fall back to status text
var statusText = status["status"]?.GetValue<string>();
if (!isActive && statusText is not null) if (!isActive && statusText is not null)
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase) isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|| string.Equals(statusText, "running", 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 // 8. Calculate workload from queue items
var workload = CalculateAgentWorkload(id, queueItems); var workload = CalculateAgentWorkload(id, queueItems);
var statusKind = DeriveStatusKind(status, isActive);
var statusDetail = DeriveStatusDetail(status, statusKind);
agents.Add(new DashboardAgentInfo( agents.Add(new DashboardAgentInfo(
Id: id, Id: id,
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name, Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
@@ -204,7 +215,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
Workload: workload, Workload: workload,
Goal: goal, Goal: goal,
RoleBadge: DeriveRoleBadge(id), RoleBadge: DeriveRoleBadge(id),
StatusLabel: DeriveStatusLabel(isActive, status), StatusLabel: DeriveStatusLabel(statusKind, isActive, statusText),
StatusKind: statusKind,
StatusDetail: statusDetail,
Elapsed: FormatElapsed(status), Elapsed: FormatElapsed(status),
Think: null, Think: null,
Next: DeriveNext(isActive, currentTask) Next: DeriveNext(isActive, currentTask)
@@ -692,6 +705,72 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
} }
} }
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default)
{
var baseUrl = httpClient.BaseAddress?.ToString().TrimEnd('/') ?? "unknown";
var requiredVersion = NormalizeOptional(configuration["Integrations:OpenClaw:RequiredVersion"]);
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/health");
ApplyAuth(request);
using var response = await httpClient.SendAsync(request, ct);
var body = await response.Content.ReadAsStringAsync(ct);
string? version = response.Headers.TryGetValues("X-OpenClaw-Version", out var headerValues)
? headerValues.FirstOrDefault()
: null;
if (string.IsNullOrWhiteSpace(version) && !string.IsNullOrWhiteSpace(body))
{
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
version = TryGetString(root, "version")
?? TryGetString(root, "gatewayVersion")
?? TryGetString(root, "openclawVersion");
}
catch
{
// Health endpoint may be plain text.
}
}
version = NormalizeOptional(version);
var pinned = requiredVersion is not null;
var versionStatus = DetermineVersionStatus(response.IsSuccessStatusCode, version, requiredVersion);
var matches = versionStatus is "matched" or "unpinned";
var message = BuildGatewayMessage(response.IsSuccessStatusCode, versionStatus, requiredVersion);
var warning = BuildGatewayWarning(response.IsSuccessStatusCode, versionStatus, version, requiredVersion, null);
return new GatewayRuntimeInfo(
response.IsSuccessStatusCode,
baseUrl,
version,
requiredVersion,
pinned,
response.IsSuccessStatusCode && matches,
versionStatus,
DateTimeOffset.UtcNow,
message,
warning);
}
catch
{
var warning = BuildGatewayWarning(false, "error", null, requiredVersion, "Gateway nicht erreichbar");
return new GatewayRuntimeInfo(
false,
baseUrl,
null,
requiredVersion,
requiredVersion is not null,
false,
"error",
DateTimeOffset.UtcNow,
"Gateway nicht erreichbar",
warning);
}
}
public async Task<bool> DeleteCronJobAsync(string id) public async Task<bool> DeleteCronJobAsync(string id)
{ {
try try
@@ -980,13 +1059,14 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
continue; continue;
// Truncate content to first 200 chars for compact display // Truncate content to first 200 chars for compact display
var text = msg.Content.Length > 200 var redacted = AgentActivityText.RedactForDisplay(msg.Content);
? msg.Content[..200] + "…" var text = redacted.Length > 200
: msg.Content; ? redacted[..200] + "…"
: redacted;
var ts = ParseTimestamp(msg.Timestamp); var ts = ParseTimestamp(msg.Timestamp);
var timeAgo = FormatTimeAgo(ts); var timeAgo = FormatTimeAgo(ts);
entries.Add(new AgentActivityEntry(timeAgo, text)); entries.Add(new AgentActivityEntry(timeAgo, text, ts));
} }
} }
catch catch
@@ -1076,25 +1156,83 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
_ => "badge-slate" _ => "badge-slate"
}; };
private static string DeriveStatusLabel(bool isActive, JsonNode? status) private static string DeriveStatusLabel(string statusKind, bool isActive, string? statusText)
{ {
if (!isActive) return "Bereit"; return statusKind switch
var statusText = status?["status"]?.GetValue<string>()?.ToLowerInvariant(); {
return statusText switch "connected" => isActive ? "Arbeitet" : "Verbunden",
"thinking" => "Plant",
"blocked" => "Blockiert",
"stale" => "Stale",
"error" => "Fehler",
"unsupported" => "Unsupported",
"ready" => "Bereit",
_ => statusText?.ToLowerInvariant() switch
{ {
"thinking" or "think" => "Plant", "thinking" or "think" => "Plant",
"blocked" or "block" => "Blockiert", "blocked" or "block" => "Blockiert",
_ => "Arbeitet" _ => isActive ? "Arbeitet" : "Bereit"
}
};
}
private static string DeriveStatusKind(JsonNode? status, bool isActive)
{
if (status is null)
return "error";
var statusText = status["status"]?.GetValue<string>()?.Trim();
var errorText = status["error"]?.GetValue<string>()?.Trim()
?? status["message"]?.GetValue<string>()?.Trim();
var normalized = statusText?.ToLowerInvariant();
var detail = $"{statusText} {errorText}".Trim().ToLowerInvariant();
if (detail.Contains("unsupported", StringComparison.Ordinal))
return "unsupported";
if (!string.IsNullOrWhiteSpace(errorText)
|| normalized is "error" or "failed" or "offline" or "disconnected" or "unreachable")
return "error";
if (normalized is "blocked" or "block")
return "blocked";
if (normalized is "thinking" or "think")
return "thinking";
var lastActivity = TryGetStatusTimestamp(status);
if (lastActivity is not null && DateTimeOffset.UtcNow - lastActivity.Value > StaleThreshold)
return "stale";
if (isActive || normalized is "active" or "running" or "connected" or "online")
return "connected";
return "ready";
}
private static string? DeriveStatusDetail(JsonNode? status, string statusKind)
{
if (status is null)
return "Gateway-Status nicht abrufbar";
var message = NormalizeOptional(status["message"]?.GetValue<string>())
?? NormalizeOptional(status["error"]?.GetValue<string>())
?? NormalizeOptional(status["detail"]?.GetValue<string>());
if (message is not null)
return message;
return statusKind switch
{
"stale" => FormatStaleDetail(TryGetStatusTimestamp(status)),
"unsupported" => "Session meldet einen nicht unterstützten Zustand",
"error" => "Session-Status konnte nicht gelesen werden",
_ => null
}; };
} }
private static string? FormatElapsed(JsonNode? status) private static string? FormatElapsed(JsonNode? status)
{ {
var lastActivity = status?["lastActivity"]?.GetValue<string>() var lastActivity = TryGetStatusTimestamp(status);
?? status?["lastMessage"]?.GetValue<string>();
if (lastActivity is null) return null; if (lastActivity is null) return null;
if (!DateTimeOffset.TryParse(lastActivity, out var ts)) return null; var diff = DateTimeOffset.UtcNow - lastActivity.Value;
var diff = DateTimeOffset.UtcNow - ts;
if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s"; if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m"; if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h"; if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h";
@@ -1120,4 +1258,96 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
"main" => "Assistant", "main" => "Assistant",
_ => "Custom" _ => "Custom"
}; };
private static string? TryGetString(JsonElement root, string property)
=> root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty(property, out var value)
&& value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
public static string RedactSensitiveText(string content)
{
if (string.IsNullOrWhiteSpace(content))
return content;
var lines = content.Split('\n');
for (var i = 0; i < lines.Length; i++)
{
var lower = lines[i].ToLowerInvariant();
if (SensitiveMarkers.Any(marker => lower.Contains(marker, StringComparison.OrdinalIgnoreCase)))
{
lines[i] = "[redacted sensitive line]";
}
}
return string.Join('\n', lines);
}
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static DateTimeOffset? TryGetStatusTimestamp(JsonNode? status)
{
var raw = status?["lastActivity"]?.GetValue<string>()
?? status?["lastMessage"]?.GetValue<string>()
?? status?["updatedAt"]?.GetValue<string>();
return DateTimeOffset.TryParse(raw, out var ts) ? ts : null;
}
private static string DetermineVersionStatus(bool reachable, string? version, string? requiredVersion)
{
if (!reachable)
return "error";
if (requiredVersion is null)
return version is null ? "unknown" : "unpinned";
if (version is null)
return "missing";
return string.Equals(version, requiredVersion, StringComparison.OrdinalIgnoreCase) ? "matched" : "drift";
}
private static string BuildGatewayMessage(bool reachable, string versionStatus, string? requiredVersion)
{
if (!reachable)
return "Gateway nicht erreichbar";
return versionStatus switch
{
"matched" => "Gateway erreichbar und Version gepinnt",
"missing" => requiredVersion is null
? "Gateway erreichbar"
: $"Gateway erreichbar, aber Versionspin {requiredVersion} nicht nachweisbar",
"drift" => "Gateway erreichbar, aber Version weicht vom Pin ab",
"unpinned" => "Gateway erreichbar",
"unknown" => "Gateway erreichbar, Version nicht erkannt",
_ => "Gateway erreichbar"
};
}
private static string? BuildGatewayWarning(bool reachable, string versionStatus, string? version, string? requiredVersion, string? fallback)
{
if (!reachable)
return fallback ?? "Gateway nicht erreichbar";
return versionStatus switch
{
"missing" when requiredVersion is not null => $"Gateway meldet keine Version; erwartet wird {requiredVersion}.",
"drift" when requiredVersion is not null => $"Gateway meldet {version ?? "unknown"} statt {requiredVersion}.",
"unknown" => "Gateway-Version konnte nicht erkannt werden.",
_ => null
};
}
private static string? FormatStaleDetail(DateTimeOffset? lastActivity)
{
if (lastActivity is null)
return "Letzte Aktivität ist veraltet";
var diff = DateTimeOffset.UtcNow - lastActivity.Value;
if (diff.TotalMinutes < 60)
return $"Keine neue Aktivität seit {(int)diff.TotalMinutes}m";
if (diff.TotalHours < 24)
return $"Keine neue Aktivität seit {(int)diff.TotalHours}h";
return $"Keine neue Aktivität seit {(int)diff.TotalDays}d";
}
} }
@@ -0,0 +1,46 @@
using Microsoft.Extensions.Options;
namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryBackgroundService(
IServiceScopeFactory scopeFactory,
IOptionsMonitor<StaleTaskRecoveryOptions> optionsMonitor,
ILogger<StaleTaskRecoveryBackgroundService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var resetCount = await RunRecoveryOnceAsync(stoppingToken);
if (resetCount > 0)
logger.LogInformation("Stale task recovery reset {ResetCount} task(s).", resetCount);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Stale task recovery run failed.");
}
try
{
await Task.Delay(optionsMonitor.CurrentValue.GetInterval(), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
public async Task<int> RunRecoveryOnceAsync(CancellationToken ct = default)
{
await using var scope = scopeFactory.CreateAsyncScope();
var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>();
return await recoveryService.ResetStaleInProgressTasksAsync(optionsMonitor.CurrentValue.GetStaleThreshold(), ct);
}
}
@@ -0,0 +1,13 @@
namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryOptions
{
public const string SectionName = "TaskRecovery";
public int StaleHours { get; set; } = 2;
public int IntervalMinutes { get; set; } = 30;
public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours));
public TimeSpan GetInterval() => TimeSpan.FromMinutes(Math.Max(1, IntervalMinutes));
}
@@ -0,0 +1,209 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryService(
ITaskRepository taskRepository,
IActivityRepository activityRepository,
ILiveUpdateService liveUpdateService) : IStaleTaskRecoveryService
{
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var staleTasks = await GetStaleTasksAsync(threshold, ct);
if (staleTasks.Count == 0)
return 0;
var latestActivityByTaskId = await GetLatestActivityByTaskIdAsync(staleTasks.Select(task => task.Id), ct);
var now = DateTimeOffset.UtcNow;
var resetCount = 0;
foreach (var task in staleTasks)
{
var currentTask = await taskRepository.GetByIdAsync(task.Id, ct);
if (currentTask is null || !IsStaleInProgress(currentTask, threshold))
continue;
latestActivityByTaskId.TryGetValue(currentTask.Id, out var lastActivityAt);
var message = BuildActivityMessage(currentTask, staleThreshold, now, lastActivityAt);
var updated = await taskRepository.TryResetStaleInProgressToBacklogAsync(
currentTask.Id,
threshold,
now,
ct);
if (!updated)
continue;
await activityRepository.AddAsync(new ActivityEvent
{
Type = "task",
Message = message,
TaskId = task.Id
}, ct);
resetCount++;
}
if (resetCount > 0)
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
return resetCount;
}
private async Task<List<WorkTask>> GetStaleTasksAsync(DateTimeOffset threshold, CancellationToken ct)
{
var allTasks = await taskRepository.GetAllAsync(ct);
return allTasks
.Where(task => IsStaleInProgress(task, threshold))
.ToList();
}
private static bool IsStaleInProgress(WorkTask task, DateTimeOffset threshold)
=> string.Equals(task.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase)
&& task.UpdatedAt < threshold;
private async Task<Dictionary<Guid, DateTimeOffset>> GetLatestActivityByTaskIdAsync(
IEnumerable<Guid> taskIds,
CancellationToken ct)
{
var activities = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
return activities
.Where(activity => activity.TaskId.HasValue)
.GroupBy(activity => activity.TaskId!.Value)
.ToDictionary(group => group.Key, group => group.Max(activity => activity.CreatedAt));
}
private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct)
{
var allTasks = await taskRepository.GetAllAsync(ct);
var taskIds = allTasks.Select(task => task.Id).ToList();
var activity = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
var backlog = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>();
var review = new List<DashboardTaskDto>();
var blocked = new List<DashboardTaskDto>();
var done = new List<DashboardTaskDto>();
foreach (var task in allTasks)
{
var dto = MapToDtoWithChildren(task, allTasks, activity);
switch (task.State.ToLowerInvariant())
{
case "backlog": backlog.Add(dto); break;
case "in progress": inProgress.Add(dto); break;
case "review": review.Add(dto); break;
case "blocked": blocked.Add(dto); break;
case "done": done.Add(dto); break;
default: backlog.Add(dto); break;
}
}
backlog.Sort(SortByPriorityThenCreatedAt);
inProgress.Sort(SortByPriorityThenCreatedAt);
review.Sort(SortByPriorityThenCreatedAt);
blocked.Sort(SortByPriorityThenCreatedAt);
done.Sort(SortByPriorityThenCreatedAt);
return new BoardResponse(backlog, inProgress, review, blocked, done);
}
private static DashboardTaskDto MapToDtoWithChildren(
WorkTask task,
IReadOnlyList<WorkTask> allTasks,
IEnumerable<ActivityEvent> activity)
{
var childTasks = allTasks
.Where(candidate => candidate.ParentTaskId == task.Id)
.OrderByDescending(candidate => candidate.UpdatedAt)
.ToList();
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity)).ToList();
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity);
return dto with
{
ChildTasks = childDtos,
ChildTaskCount = childDtos.Count,
OpenChildTaskCount = openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
};
}
private static DashboardTaskDto MapToDtoWithActivity(WorkTask task, IEnumerable<ActivityEvent> activity)
{
var last = activity
.Where(entry => entry.TaskId == task.Id)
.OrderByDescending(entry => entry.CreatedAt)
.FirstOrDefault();
return new DashboardTaskDto(
task.Id,
task.Title,
task.Detail,
task.Source,
task.State,
task.Priority,
task.AssignedTo,
task.ParentTaskId,
task.DueDate,
task.CreatedAt,
task.UpdatedAt,
task.IsAgentTask,
task.ExpectedFrom,
last?.Message,
last?.CreatedAt,
null,
0,
0,
task.ParentTaskId.HasValue || task.IsAgentTask);
}
private static string BuildActivityMessage(
WorkTask task,
TimeSpan staleThreshold,
DateTimeOffset now,
DateTimeOffset? lastActivityAt)
{
var staleAge = now - task.UpdatedAt;
var details = new List<string>
{
"reason=stale-recovery",
"previous status In progress",
$"stale reference {now:O}",
$"stale age {FormatDuration(staleAge)}",
$"threshold {FormatDuration(staleThreshold)}"
};
if (lastActivityAt.HasValue)
details.Add($"last activity {lastActivityAt.Value:O}");
details.Add($"last update {task.UpdatedAt:O}");
details.Add("new status Backlog");
return $"Task \"{task.Title}\" reset from In progress to Backlog by stale recovery ({string.Join("; ", details)})";
}
private static string FormatDuration(TimeSpan duration)
=> duration.ToString(@"dd\.hh\:mm\:ss");
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
{
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt);
}
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
{
"high" => 3,
"medium" => 2,
"normal" => 2,
"low" => 1,
_ => 2
};
}
+4 -25
View File
@@ -11,7 +11,8 @@ public sealed class TaskService(
INotificationService notificationService, INotificationService notificationService,
IAgentService agentService, IAgentService agentService,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ILiveUpdateService liveUpdateService) : ITaskService ILiveUpdateService liveUpdateService,
IStaleTaskRecoveryService staleTaskRecoveryService) : ITaskService
{ {
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default) public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> await taskRepo.GetAllAsync(ct); => await taskRepo.GetAllAsync(ct);
@@ -495,30 +496,8 @@ public sealed class TaskService(
return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct); return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct);
} }
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default) public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{ => staleTaskRecoveryService.ResetStaleInProgressTasksAsync(staleThreshold, ct);
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 async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default) public async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default)
{ {
+5
View File
@@ -5,6 +5,7 @@
"Integrations": { "Integrations": {
"OpenClaw": { "OpenClaw": {
"BaseUrl": "http://127.0.0.1:18789", "BaseUrl": "http://127.0.0.1:18789",
"RequiredVersion": "",
"Token": "", "Token": "",
"Password": "" "Password": ""
}, },
@@ -21,5 +22,9 @@
"AccessTokenExpirationMinutes": 15, "AccessTokenExpirationMinutes": 15,
"RefreshTokenExpirationDays": 7 "RefreshTokenExpirationDays": 7
}, },
"TaskRecovery": {
"StaleHours": 2,
"IntervalMinutes": 30
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }
+16 -1
View File
@@ -10,6 +10,7 @@ Diese Datei beschreibt den gewünschten und umgesetzten Arbeitsfluss zwischen:
- **Sub-Agenten** als ausführende Spezialisten - **Sub-Agenten** als ausführende Spezialisten
- **OpenClaw** als Agent-Runtime - **OpenClaw** als Agent-Runtime
- **Nexus Task Board** als sichtbare Aufgabenquelle - **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 - **Child-Task** = konkrete Arbeitsaufgabe für einen Spezial-Agenten
- **Board** = sichtbare Wahrheit für Aufgabenstatus und Ownership - **Board** = sichtbare Wahrheit für Aufgabenstatus und Ownership
- **OpenClaw** = Ausführungspfad für Agentenarbeit - **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 Iris -->|delegiert konkrete Arbeit| OC
OC -->|führt Agenten-Task aus| Agents OC -->|führt Agenten-Task aus| Agents
Iris -->|legt Child-Tasks an| Board Iris -->|legt Child-Tasks an| Board
Agents -->|arbeiten gegen Child-Tasks| Board Agents -->|MCP Tools /mcp| Board
Agents -->|liefern Ergebnis / melden Blocker| Iris Agents -->|liefern Ergebnis / melden Blocker| Iris
Iris -->|integriert Ergebnis| Board Iris -->|integriert Ergebnis| Board
Board -->|Review für Bao| Bao Board -->|Review für Bao| Bao
@@ -89,6 +92,13 @@ flowchart LR
- liefert Nachrichten, Status und Arbeitsergebnisse zurück - liefert Nachrichten, Status und Arbeitsergebnisse zurück
- ersetzt nicht das Board als Aufgabenwahrheit - 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 ### Nexus Task Board
- ist die **sichtbare operative Quelle** für Aufgaben - ist die **sichtbare operative Quelle** für Aufgaben
- zeigt Parent-Task, Child-Tasks, Ownership und Status - 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 - 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 - Board-Spalten und API-State-Mapping müssen das Parent-/Child-Modell sauber abbilden
- UI und Doku müssen dieselbe Sprache sprechen - 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.
--- ---
+113 -2
View File
@@ -4,7 +4,8 @@ import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, C
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types' import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
import { TASK_STATES } from '../types' import { TASK_STATES } from '../types'
import { apiFetch } from '../services/api' 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 props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -13,8 +14,13 @@ const emit = defineEmits<{
updateTaskState: [id: string, state: string] updateTaskState: [id: string, state: string]
}>() }>()
const store = useOperationsStore() const store = useOperationsStore()
const auth = useAuthStore()
const agents = ref<AgentInfo[]>([]) const agents = ref<AgentInfo[]>([])
const agentsLoading = ref(false) const agentsLoading = ref(false)
const pendingApprovals = ref<PendingApprovalTask[]>([])
const pendingApprovalsLoading = ref(false)
const pendingApprovalsError = ref('')
const canModerateApprovals = computed(() => auth.user?.role === 'owner')
async function loadAgents() { async function loadAgents() {
if (agentsLoading.value) return if (agentsLoading.value) return
@@ -23,12 +29,43 @@ async function loadAgents() {
agentsLoading.value = false 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(() => { onMounted(() => {
if (props.view === 'Agents') loadAgents() if (props.view === 'Agents') loadAgents()
if (props.view === 'Task Board') void loadPendingApprovals()
}) })
watch(() => props.view, (v) => { watch(() => props.view, (v) => {
if (v === 'Agents') loadAgents() 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('') const newProject = ref('')
@@ -51,6 +88,7 @@ async function handleApproveTask(id: string) {
taskActionError.value = '' taskActionError.value = ''
try { try {
await store.approveTask(id) await store.approveTask(id)
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
} catch (e) { } catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task' taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task'
} finally { } finally {
@@ -63,6 +101,7 @@ async function handleRejectTask(id: string) {
taskActionError.value = '' taskActionError.value = ''
try { try {
await store.rejectTask(id) await store.rejectTask(id)
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
} catch (e) { } catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task' taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task'
} finally { } finally {
@@ -187,6 +226,31 @@ async function sendMessage() {
</div> </div>
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form> <form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form>
<section v-if="view === 'Task Board' && canModerateApprovals" class="approval-strip">
<header class="approval-strip-head">
<div>
<span class="kicker">Owner approvals</span>
<h3>Pending approvals</h3>
</div>
<span class="badge">{{ pendingApprovals.length }}</span>
</header>
<p v-if="pendingApprovalsLoading" class="approval-strip-note">Loading owner approval queue</p>
<p v-else-if="pendingApprovalsError" class="approval-strip-note error">{{ pendingApprovalsError }}</p>
<p v-else-if="!pendingApprovals.length" class="approval-strip-note">No tasks are waiting for Bao approval.</p>
<div v-else class="approval-list">
<article v-for="task in pendingApprovals" :key="task.id" class="approval-card">
<div>
<strong>{{ task.title }}</strong>
<p>{{ task.priority }} · {{ new Date(task.updatedAt).toLocaleString() }}</p>
</div>
<div class="approval-actions">
<button class="task-approve-btn" :disabled="approvingTaskId === task.id" @click="handleApproveTask(task.id)"><CheckCircle2 :size="13" /></button>
<button class="task-reject-btn" :disabled="approvingTaskId === task.id" @click="handleRejectTask(task.id)"><X :size="13" /></button>
</div>
</article>
</div>
<p v-if="taskActionError" class="approval-strip-note error">{{ taskActionError }}</p>
</section>
<div v-if="view === 'Task Board'" class="kanban"> <div v-if="view === 'Task Board'" class="kanban">
<section v-for="column in columns" :key="column.name" class="kanban-column"> <section v-for="column in columns" :key="column.name" class="kanban-column">
<header><span>{{ column.name }}</span><b>{{ column.items.length }}</b></header> <header><span>{{ column.name }}</span><b>{{ column.items.length }}</b></header>
@@ -214,7 +278,7 @@ async function sendMessage() {
<div class="task-card-head"> <div class="task-card-head">
<span :class="['priority', task.priority.toLowerCase()]">{{ task.priority }}</span> <span :class="['priority', task.priority.toLowerCase()]">{{ task.priority }}</span>
<div class="task-card-actions"> <div class="task-card-actions">
<template v-if="task.state === 'In progress'"> <template v-if="task.state === 'In progress' && canModerateApprovals">
<button <button
class="task-approve-btn" class="task-approve-btn"
title="Approve" title="Approve"
@@ -338,6 +402,53 @@ async function sendMessage() {
</template> </template>
<style scoped> <style scoped>
.approval-strip {
margin: 0 0 18px;
padding: 14px 16px;
border: 1px solid var(--line, #1e2030);
border-radius: 14px;
background: rgba(255,255,255,.025);
}
.approval-strip-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.approval-strip-head h3 {
margin: 2px 0 0;
}
.approval-strip-note {
margin: 10px 0 0;
color: #8e96a8;
}
.approval-strip-note.error {
color: #e16e75;
}
.approval-list {
display: grid;
gap: 10px;
margin-top: 12px;
}
.approval-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid rgba(255,255,255,.06);
border-radius: 12px;
background: rgba(8, 10, 18, .35);
}
.approval-card p {
margin: 4px 0 0;
color: #8e96a8;
font-size: 12px;
}
.approval-actions {
display: flex;
gap: 8px;
}
.task-card-head { .task-card-head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -10,6 +10,9 @@ defineProps<{
saving: boolean saving: boolean
saveStatus: 'idle' | 'saved' | 'error' saveStatus: 'idle' | 'saved' | 'error'
saveMessage: string saveMessage: string
backupStatus: string
reloadStatus: string
reloadMessage: string
}>() }>()
defineEmits<{ defineEmits<{
@@ -60,6 +63,12 @@ function onInput(event: Event) {
</div> </div>
</div> </div>
<div v-if="reloadMessage" class="editor-health">
<span class="health-pill" :class="backupStatus">Backup {{ backupStatus }}</span>
<span class="health-pill" :class="reloadStatus">Reload {{ reloadStatus }}</span>
<span class="health-note">{{ reloadMessage }}</span>
</div>
<!-- Text editor --> <!-- Text editor -->
<textarea <textarea
class="config-editor" class="config-editor"
@@ -89,6 +98,17 @@ function onInput(event: Event) {
border-bottom: 1px solid var(--line, #1e2030); border-bottom: 1px solid var(--line, #1e2030);
gap: 12px; gap: 12px;
} }
.editor-health {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-bottom: 1px solid var(--line, #1e2030);
background: rgba(255,255,255,.015);
color: #8e96a8;
font-size: 10.5px;
flex-wrap: wrap;
}
.editor-file-info { .editor-file-info {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -165,6 +185,30 @@ function onInput(event: Event) {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
.health-pill {
display: inline-flex;
align-items: center;
border: 1px solid var(--line, #1e2030);
border-radius: 999px;
padding: 2px 8px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.health-pill.created {
color: #51d49a;
border-color: rgba(81,212,154,.3);
}
.health-pill.not_applicable {
color: #d4b26a;
border-color: rgba(212,178,106,.25);
}
.health-pill.not_supported {
color: #9aa4bb;
border-color: rgba(154,164,187,.25);
}
.health-note {
color: #7e8799;
}
.config-editor { .config-editor {
width: 100%; width: 100%;
+22 -2
View File
@@ -2,6 +2,15 @@ import { defineStore } from 'pinia'
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types' import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
import { apiFetch } from '../services/api' import { apiFetch } from '../services/api'
export interface PendingApprovalTask {
id: string
title: string
state: string
priority: string
projectId?: string | null
updatedAt: string
}
const fallback: OperationsSnapshot = { const fallback: OperationsSnapshot = {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
runtime: { runtime: 'OpenClaw', status: 'Unknown', detail: 'Awaiting connection…' }, runtime: { runtime: 'OpenClaw', status: 'Unknown', detail: 'Awaiting connection…' },
@@ -22,6 +31,11 @@ export const useOperationsStore = defineStore('operations', {
connected: false, connected: false,
}), }),
actions: { actions: {
async fetchPendingApprovals(): Promise<PendingApprovalTask[]> {
const response = await apiFetch('/api/v1/tasks/pending-approval')
if (!response.ok) throw new Error('Pending approvals could not be loaded')
return await response.json()
},
async createProject(name: string) { async createProject(name: string) {
const response = await apiFetch('/api/v1/projects', { const response = await apiFetch('/api/v1/projects', {
method: 'POST', method: 'POST',
@@ -145,7 +159,10 @@ export const useOperationsStore = defineStore('operations', {
const response = await apiFetch(`/api/v1/tasks/${id}/approve`, { const response = await apiFetch(`/api/v1/tasks/${id}/approve`, {
method: 'POST', method: 'POST',
}) })
if (!response.ok) throw new Error('Task could not be approved') if (!response.ok) {
const err = await response.json().catch(() => ({ detail: 'Task could not be approved' }))
throw new Error(err.detail || 'Task could not be approved')
}
const index = this.snapshot.tasks.findIndex(task => task.id === id) const index = this.snapshot.tasks.findIndex(task => task.id === id)
if (index !== -1) { if (index !== -1) {
this.snapshot.tasks.splice(index, 1) this.snapshot.tasks.splice(index, 1)
@@ -161,7 +178,10 @@ export const useOperationsStore = defineStore('operations', {
const response = await apiFetch(`/api/v1/tasks/${id}/reject`, { const response = await apiFetch(`/api/v1/tasks/${id}/reject`, {
method: 'POST', method: 'POST',
}) })
if (!response.ok) throw new Error('Task could not be rejected') if (!response.ok) {
const err = await response.json().catch(() => ({ detail: 'Task could not be rejected' }))
throw new Error(err.detail || 'Task could not be rejected')
}
const index = this.snapshot.tasks.findIndex(task => task.id === id) const index = this.snapshot.tasks.findIndex(task => task.id === id)
if (index !== -1) { if (index !== -1) {
this.snapshot.tasks[index] = { ...this.snapshot.tasks[index], state: 'Backlog' } this.snapshot.tasks[index] = { ...this.snapshot.tasks[index], state: 'Backlog' }
+435 -5
View File
@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed } from 'vue' import { onMounted, onUnmounted, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Bot, Loader2, AlertCircle, Activity } from '@lucide/vue' import { ArrowLeft, Bot, Loader2, AlertCircle, Activity, RefreshCw } from '@lucide/vue'
import { apiFetch } from '../services/api' import { apiFetch } from '../services/api'
import type { AgentDetail } from '../types' import type { AgentDetail } from '../types'
import ConfigTabs from '../components/config/ConfigTabs.vue' import ConfigTabs from '../components/config/ConfigTabs.vue'
import ConfigEditor from '../components/config/ConfigEditor.vue' import ConfigEditor from '../components/config/ConfigEditor.vue'
import { openDashboardLiveStream } from '../services/live'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -18,6 +19,19 @@ const configFiles = ref<ConfigFileInfo[]>([])
const activeTab = ref(0) const activeTab = ref(0)
const configsLoading = ref(false) const configsLoading = ref(false)
const configsError = ref('') const configsError = ref('')
const activityItems = ref<AgentActivityItem[]>([])
const activityLoading = ref(false)
const activityError = ref('')
const summaryLoading = ref(false)
const summaryError = ref('')
const summary = ref<AgentSummary | null>(null)
const liveConnected = ref(false)
const liveUnavailable = ref(false)
let liveAbort: AbortController | null = null
let activityReloadTimer: ReturnType<typeof setTimeout> | null = null
let liveReconnectTimer: ReturnType<typeof setTimeout> | null = null
let liveStreamStopped = false
let lastLiveSequence = 0
const initLoading = ref(true) const initLoading = ref(true)
@@ -28,6 +42,9 @@ interface EditorState {
dirty: boolean dirty: boolean
saveStatus: 'idle' | 'saved' | 'error' saveStatus: 'idle' | 'saved' | 'error'
saveMessage: string saveMessage: string
backupStatus: string
reloadStatus: string
reloadMessage: string
} }
interface ConfigFileInfo { interface ConfigFileInfo {
@@ -40,6 +57,46 @@ interface ConfigFileDetail extends ConfigFileInfo {
content: string content: string
} }
interface AgentActivityItem {
id: number | null
type: string
message: string
at: string
source: string
relativeTime?: string | null
}
interface AgentSummary {
now: AgentSummaryItem
today: AgentSummaryItem
generatedAt: string
}
interface AgentSummaryItem {
text: string
source: string
timestamp?: string | null
}
interface SaveConfigResult {
fileName: string
size: number
modifiedAt: string
validation: {
status: string
fileKind: string
errors: string[]
}
backup: {
status: string
backupCreated: boolean
}
reloadCheck: {
status: string
message: string
}
}
const editorState = ref<EditorState>({ const editorState = ref<EditorState>({
content: '', content: '',
savedContent: '', savedContent: '',
@@ -47,6 +104,9 @@ const editorState = ref<EditorState>({
dirty: false, dirty: false,
saveStatus: 'idle', saveStatus: 'idle',
saveMessage: '', saveMessage: '',
backupStatus: 'not_applicable',
reloadStatus: 'not_supported',
reloadMessage: '',
}) })
const agentId = route.params.id as string const agentId = route.params.id as string
@@ -99,6 +159,22 @@ function formatLastSeen(dateStr?: string): string {
}) })
} }
function formatActivityTime(item: AgentActivityItem): string {
if (item.relativeTime) return item.relativeTime
const d = new Date(item.at)
return d.toLocaleDateString('de-DE', {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function activityTypeLabel(type: string): string {
if (type === 'thinking') return 'Thinking'
if (type === 'handoff') return 'Handoff'
if (type === 'task') return 'Task'
return 'Activity'
}
async function loadAgent() { async function loadAgent() {
loading.value = true loading.value = true
error.value = '' error.value = ''
@@ -113,6 +189,110 @@ async function loadAgent() {
} }
} }
async function loadActivity() {
activityLoading.value = true
activityError.value = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/activity`)
if (!response.ok) throw new Error('Failed to load activity')
activityItems.value = await response.json()
} catch (e) {
activityError.value = e instanceof Error ? e.message : 'Failed to load activity'
} finally {
activityLoading.value = false
}
}
async function loadSummary() {
summaryLoading.value = true
summaryError.value = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/summary`)
if (!response.ok) throw new Error('Failed to load summary')
summary.value = await response.json()
} catch (e) {
summaryError.value = e instanceof Error ? e.message : 'Failed to load summary'
} finally {
summaryLoading.value = false
}
}
function scheduleActivityReload() {
if (activityReloadTimer) return
activityReloadTimer = setTimeout(async () => {
activityReloadTimer = null
await loadActivity()
await loadSummary()
}, 250)
}
function formatSummaryTimestamp(value?: string | null): string {
if (!value) return 'No timestamp'
const d = new Date(value)
return d.toLocaleDateString('de-DE', {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function summarySourceLabel(source: string): string {
switch (source) {
case 'nexus-activity': return 'Nexus activity'
case 'gateway-session-history': return 'Gateway history'
case 'derived-mixed': return 'Derived from mixed feed'
case 'none': return 'No data'
default: return source
}
}
function scheduleStreamReconnect() {
if (liveStreamStopped || liveReconnectTimer) return
liveReconnectTimer = setTimeout(() => {
liveReconnectTimer = null
void connectActivityStream()
}, 1500)
}
async function connectActivityStream() {
liveAbort?.abort()
liveAbort = new AbortController()
try {
const stream = await openDashboardLiveStream((event, data) => {
const cursor = (data as any)?.cursor
if (typeof cursor?.sequence === 'number') lastLiveSequence = cursor.sequence
if (event === 'snapshot') {
liveConnected.value = true
liveUnavailable.value = false
return
}
if (event !== 'update') return
const envelope = (data as any)?.envelope
if (envelope?.type !== 'activity.created') return
const agentIds = Array.isArray(envelope?.payload?.agentIds)
? envelope.payload.agentIds.map((id: unknown) => String(id).toLowerCase())
: []
if (agentIds.includes(agentId.toLowerCase())) {
scheduleActivityReload()
}
}, { signal: liveAbort.signal, afterSequence: lastLiveSequence || null })
await stream.closed
if (!liveStreamStopped) {
liveConnected.value = false
scheduleStreamReconnect()
}
} catch {
liveConnected.value = false
liveUnavailable.value = true
if (!liveStreamStopped) scheduleStreamReconnect()
}
}
async function loadConfigFiles() { async function loadConfigFiles() {
configsLoading.value = true configsLoading.value = true
configsError.value = '' configsError.value = ''
@@ -147,6 +327,9 @@ async function loadFileContent(fileName: string) {
dirty: false, dirty: false,
saveStatus: 'idle', saveStatus: 'idle',
saveMessage: '', saveMessage: '',
backupStatus: 'not_applicable',
reloadStatus: 'not_supported',
reloadMessage: '',
} }
} catch (e) { } catch (e) {
editorState.value = { editorState.value = {
@@ -156,6 +339,9 @@ async function loadFileContent(fileName: string) {
dirty: false, dirty: false,
saveStatus: 'error', saveStatus: 'error',
saveMessage: e instanceof Error ? e.message : `Failed to load ${fileName}`, saveMessage: e instanceof Error ? e.message : `Failed to load ${fileName}`,
backupStatus: 'not_applicable',
reloadStatus: 'not_supported',
reloadMessage: '',
} }
} }
} }
@@ -180,6 +366,9 @@ async function saveFile() {
editorState.value.saving = true editorState.value.saving = true
editorState.value.saveStatus = 'idle' editorState.value.saveStatus = 'idle'
editorState.value.saveMessage = '' editorState.value.saveMessage = ''
editorState.value.backupStatus = 'not_applicable'
editorState.value.reloadStatus = 'not_supported'
editorState.value.reloadMessage = ''
try { try {
const response = await apiFetch(`/api/v1/agents/${agentId}/config/${encodeURIComponent(fileName)}`, { const response = await apiFetch(`/api/v1/agents/${agentId}/config/${encodeURIComponent(fileName)}`, {
@@ -190,14 +379,21 @@ async function saveFile() {
if (!response.ok) { if (!response.ok) {
const err = await response.json().catch(() => ({})) const err = await response.json().catch(() => ({}))
throw new Error((err as any).error || 'Failed to save file') const problem = err as { error?: string; errors?: Record<string, string[]> }
const detail = problem.error
|| Object.values(problem.errors ?? {}).flat().join(' ')
|| 'Failed to save file'
throw new Error(detail)
} }
const result: { fileName: string; size: number; modifiedAt: string } = await response.json() const result: SaveConfigResult = await response.json()
editorState.value.savedContent = editorState.value.content editorState.value.savedContent = editorState.value.content
editorState.value.dirty = false editorState.value.dirty = false
editorState.value.saveStatus = 'saved' editorState.value.saveStatus = 'saved'
editorState.value.saveMessage = 'Gespeichert' editorState.value.saveMessage = `Gespeichert · Backup ${result.backup.status}`
editorState.value.backupStatus = result.backup.status
editorState.value.reloadStatus = result.reloadCheck.status
editorState.value.reloadMessage = result.reloadCheck.message
const idx = configFiles.value.findIndex(f => f.fileName === fileName) const idx = configFiles.value.findIndex(f => f.fileName === fileName)
if (idx >= 0) { if (idx >= 0) {
@@ -213,19 +409,33 @@ async function saveFile() {
} catch (e) { } catch (e) {
editorState.value.saveStatus = 'error' editorState.value.saveStatus = 'error'
editorState.value.saveMessage = e instanceof Error ? e.message : 'Failed to save file' editorState.value.saveMessage = e instanceof Error ? e.message : 'Failed to save file'
editorState.value.backupStatus = 'not_applicable'
editorState.value.reloadStatus = 'not_supported'
} finally { } finally {
editorState.value.saving = false editorState.value.saving = false
} }
} }
onMounted(async () => { onMounted(async () => {
liveStreamStopped = false
initLoading.value = true initLoading.value = true
await Promise.allSettled([ await Promise.allSettled([
loadAgent(), loadAgent(),
loadConfigFiles(), loadConfigFiles(),
loadActivity(),
loadSummary(),
]) ])
connectActivityStream()
initLoading.value = false initLoading.value = false
}) })
onUnmounted(() => {
liveStreamStopped = true
liveAbort?.abort()
liveAbort = null
if (activityReloadTimer) clearTimeout(activityReloadTimer)
if (liveReconnectTimer) clearTimeout(liveReconnectTimer)
})
</script> </script>
<template> <template>
@@ -269,6 +479,85 @@ onMounted(async () => {
</div> </div>
</div> </div>
<section class="thinking-section">
<header class="section-head">
<div>
<span class="eyebrow">LIVE</span>
<h2>Thinking <span :class="['live-dot', { on: liveConnected }]"></span></h2>
<p class="section-note">
Nexus activity streams live. Gateway session history remains read-only fallback and refreshes when related Nexus events arrive or on manual reload.
</p>
</div>
<button class="icon-button" :disabled="activityLoading || summaryLoading" @click="Promise.allSettled([loadActivity(), loadSummary()])">
<RefreshCw :size="14" :class="{ spin: activityLoading }" />
</button>
</header>
<div v-if="summaryLoading && !summary" class="status-message compact">
<Loader2 :size="16" class="spin" />
Loading summaries...
</div>
<div v-else-if="summaryError && !summary" class="status-message compact error">
<AlertCircle :size="16" />
{{ summaryError }}
</div>
<div v-else-if="summary" class="summary-row">
<div class="summary-card">
<span>Now</span>
<p>{{ summary.now.text }}</p>
<small>{{ summarySourceLabel(summary.now.source) }} · {{ formatSummaryTimestamp(summary.now.timestamp) }}</small>
</div>
<div class="summary-card">
<span>Today</span>
<p>{{ summary.today.text }}</p>
<small>{{ summarySourceLabel(summary.today.source) }} · {{ formatSummaryTimestamp(summary.today.timestamp) }}</small>
</div>
</div>
<div v-else class="status-message compact">
No summary available.
</div>
<div v-if="summaryError && summary" class="status-message compact error summary-inline-error">
<AlertCircle :size="14" />
{{ summaryError }}
</div>
<div v-if="liveUnavailable" class="status-message compact">
Live stream reconnecting
</div>
<div v-if="activityLoading && !activityItems.length" class="status-message compact">
<Loader2 :size="16" class="spin" />
Loading activity...
</div>
<div v-else-if="activityError" class="status-message compact error">
<AlertCircle :size="16" />
{{ activityError }}
</div>
<div v-else-if="activityItems.length" class="thinking-list">
<article
v-for="item in activityItems"
:key="`${item.source}-${item.id ?? item.at}-${item.message}`"
class="thinking-item"
>
<div class="thinking-meta">
<span class="type-pill">{{ activityTypeLabel(item.type) }}</span>
<span>{{ formatActivityTime(item) }}</span>
</div>
<p>{{ item.message }}</p>
</article>
</div>
<div v-else class="status-message compact">
No recent activity.
</div>
</section>
<!-- Config section --> <!-- Config section -->
<div class="config-section"> <div class="config-section">
<div v-if="configsLoading" class="status-message"> <div v-if="configsLoading" class="status-message">
@@ -297,6 +586,9 @@ onMounted(async () => {
:saving="editorState.saving" :saving="editorState.saving"
:save-status="editorState.saveStatus" :save-status="editorState.saveStatus"
:save-message="editorState.saveMessage" :save-message="editorState.saveMessage"
:backup-status="editorState.backupStatus"
:reload-status="editorState.reloadStatus"
:reload-message="editorState.reloadMessage"
@update-content="onContentChange" @update-content="onContentChange"
@save="saveFile" @save="saveFile"
/> />
@@ -343,6 +635,9 @@ onMounted(async () => {
.status-message.error { .status-message.error {
color: #e16e75; color: #e16e75;
} }
.status-message.compact {
padding: 20px;
}
.spin { .spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@@ -416,9 +711,144 @@ onMounted(async () => {
.status-label.mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; } .status-label.mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
.status-sep { color: #3d4152; font-size: 11px; } .status-sep { color: #3d4152; font-size: 11px; }
.thinking-section {
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
margin-bottom: 16px;
overflow: hidden;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.section-head .eyebrow {
display: block;
font-size: 8.5px;
font-weight: 700;
letter-spacing: .12em;
color: var(--accent, #7b6ef2);
}
.section-head h2 {
margin: 2px 0 0;
color: #e8eaf0;
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
}
.section-note {
margin: 6px 0 0;
color: #6f788b;
font-size: 10px;
line-height: 1.45;
max-width: 560px;
}
.live-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: #6b7385;
}
.live-dot.on {
background: #51d49a;
}
.icon-button {
width: 30px;
height: 30px;
border: 1px solid var(--line);
border-radius: 7px;
background: rgba(255,255,255,.03);
color: #9ba3b5;
display: grid;
place-items: center;
cursor: pointer;
}
.icon-button:disabled {
opacity: .65;
cursor: default;
}
.thinking-list {
display: grid;
}
.summary-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: rgba(255,255,255,.05);
border-bottom: 1px solid var(--line);
}
.summary-row > div {
background: var(--panel);
padding: 12px 16px;
}
.summary-card small {
display: block;
margin-top: 7px;
color: #6f788b;
font-size: 9.5px;
line-height: 1.4;
}
.summary-row span {
display: block;
color: #6f788b;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
margin-bottom: 5px;
}
.summary-row p {
margin: 0;
color: #cbd0dc;
font-size: 11px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.thinking-item {
padding: 12px 16px;
border-bottom: 1px solid rgba(255,255,255,.05);
}
.thinking-item:last-child {
border-bottom: 0;
}
.thinking-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
color: #6f788b;
font-size: 10px;
}
.type-pill {
padding: 2px 6px;
border-radius: 999px;
border: 1px solid rgba(123,110,242,.24);
color: #aaa1ff;
background: rgba(123,110,242,.08);
font-size: 9px;
}
.thinking-item p {
margin: 0;
color: #cbd0dc;
font-size: 11px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.summary-inline-error {
border-bottom: 1px solid var(--line);
}
@media (max-width: 640px) { @media (max-width: 640px) {
.detail-page { .detail-page {
max-width: 100%; max-width: 100%;
} }
.summary-row {
grid-template-columns: 1fr;
}
} }
</style> </style>
+283 -4
View File
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Bot, Code2, Server, Shield, Search, Terminal, Users } from '@lucide/vue' import { Bot, Code2, Server, Shield, Search, Terminal, Users, Wifi, WifiOff } from '@lucide/vue'
import { apiFetch } from '../services/api'
const router = useRouter() const router = useRouter()
@@ -12,9 +14,27 @@ interface AgentCard {
tags: string[] tags: string[]
color: string color: string
icon: string icon: string
model?: string
statusLabel?: string
statusKind?: 'connected' | 'thinking' | 'blocked' | 'ready' | 'stale' | 'error' | 'unsupported'
statusDetail?: string | null
isActive?: boolean
progress?: number
currentTask?: string | null
} }
const agents: AgentCard[] = [ interface GatewayRuntimeInfo {
reachable: boolean
version?: string | null
requiredVersion?: string | null
versionPinned: boolean
versionMatches: boolean
versionStatus: 'matched' | 'drift' | 'missing' | 'unpinned' | 'unknown' | 'error'
message?: string | null
warning?: string | null
}
const fallbackAgents: AgentCard[] = [
{ {
id: 'iris', id: 'iris',
name: 'Iris', name: 'Iris',
@@ -71,6 +91,97 @@ const agents: AgentCard[] = [
}, },
] ]
const agents = ref<AgentCard[]>([])
const gateway = ref<GatewayRuntimeInfo | null>(null)
const loading = ref(false)
const error = ref('')
const agentCount = computed(() => agents.value.length)
const hasAgents = computed(() => agents.value.length > 0)
const gatewayWarning = computed(() => gateway.value?.warning || '')
const gatewayLabel = computed(() => {
if (!gateway.value) return 'Gateway wird geprüft'
if (!gateway.value.reachable) return gateway.value.message || 'Gateway offline'
switch (gateway.value.versionStatus) {
case 'matched':
return `Pinned ${gateway.value.requiredVersion}`
case 'drift':
return 'Version drift'
case 'missing':
return 'Version fehlt'
case 'unknown':
return 'Version unbekannt'
case 'unpinned':
return gateway.value.version ? `Detected ${gateway.value.version}` : 'Unpinned'
default:
return gateway.value.message || 'Gateway online'
}
})
const gatewayChipClass = computed(() => {
if (!gateway.value) return 'neutral'
if (!gateway.value.reachable) return 'error'
if (gateway.value.warning) return 'warn'
return 'ok'
})
async function loadMissionControl() {
loading.value = true
error.value = ''
try {
const [agentsResponse, gatewayResponse] = await Promise.all([
apiFetch('/api/dashboard/agents'),
apiFetch('/api/dashboard/gateway'),
])
if (agentsResponse.ok) {
const data = await agentsResponse.json()
agents.value = data.map((item: any) => enrichAgent(item))
} else {
error.value = await readErrorMessage(agentsResponse, 'Agenten konnten nicht geladen werden')
}
if (gatewayResponse.ok) {
gateway.value = await gatewayResponse.json()
} else {
const gatewayError = await readErrorMessage(gatewayResponse, 'Gateway-Status konnte nicht geladen werden')
error.value = error.value ? `${error.value} · ${gatewayError}` : gatewayError
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Mission Control konnte nicht geladen werden'
} finally {
loading.value = false
}
}
function enrichAgent(item: any): AgentCard {
const fallback = fallbackAgents.find(a => a.id === item.id)
return {
id: item.id,
name: item.name || fallback?.name || item.id,
role: item.role || fallback?.role || 'Agent',
description: item.description || fallback?.description || 'OpenClaw agent',
tags: item.tags?.length ? item.tags : fallback?.tags ?? [],
color: fallback?.color ?? '#7e8799',
icon: fallback?.icon ?? 'bot',
model: item.model,
statusLabel: item.statusLabel,
statusKind: item.statusKind,
statusDetail: item.statusDetail,
isActive: item.isActive,
progress: item.progress,
currentTask: item.currentTask,
}
}
async function readErrorMessage(response: Response, fallback: string) {
try {
const payload = await response.json()
return payload?.error || payload?.message || fallback
} catch {
return fallback
}
}
function goToAgent(id: string) { function goToAgent(id: string) {
router.push(`/agents/${id}`) router.push(`/agents/${id}`)
} }
@@ -86,6 +197,34 @@ function resolveIcon(iconName: string) {
default: return Bot default: return Bot
} }
} }
function statusTone(agent: AgentCard) {
switch (agent.statusKind) {
case 'connected': return 'connected'
case 'thinking': return 'thinking'
case 'blocked': return 'blocked'
case 'stale': return 'stale'
case 'error': return 'error'
case 'unsupported': return 'unsupported'
default: return agent.isActive ? 'connected' : 'ready'
}
}
function statusCopy(agent: AgentCard) {
if (agent.statusDetail) return agent.statusDetail
if (agent.currentTask) return agent.currentTask
switch (agent.statusKind) {
case 'connected': return 'Session ist erreichbar.'
case 'thinking': return 'Agent plant den nächsten Schritt.'
case 'blocked': return 'Agent wartet auf Entblockung.'
case 'stale': return 'Es gab länger kein neues Signal.'
case 'error': return 'Gateway konnte den Session-Status nicht lesen.'
case 'unsupported': return 'Session meldet einen nicht unterstützten Zustand.'
default: return 'Keine aktive Aufgabe gemeldet.'
}
}
onMounted(loadMissionControl)
</script> </script>
<template> <template>
@@ -97,16 +236,28 @@ function resolveIcon(iconName: string) {
</div> </div>
<div class="header-text"> <div class="header-text">
<h1>Agents</h1> <h1>Agents</h1>
<p class="header-subtitle">{{ agents.length }} AI agents each with a real role and a real personality.</p> <p class="header-subtitle">{{ agentCount }} agents · {{ gatewayLabel }}</p>
</div>
<div class="gateway-chip" :class="gatewayChipClass">
<Wifi v-if="gateway?.reachable" :size="13" />
<WifiOff v-else :size="13" />
{{ gateway?.version || gatewayLabel }}
</div> </div>
</div> </div>
<div v-if="loading" class="load-error">Lade Gateway-Status...</div>
<div v-else-if="error" class="load-error">{{ error }}</div>
<div v-if="gatewayWarning" class="gateway-warning">
{{ gatewayWarning }}
</div>
<!-- Agent grid --> <!-- Agent grid -->
<div class="agents-grid"> <div v-if="hasAgents" class="agents-grid">
<article <article
v-for="agent in agents" v-for="agent in agents"
:key="agent.id" :key="agent.id"
class="agent-card" class="agent-card"
:class="`status-${statusTone(agent)}`"
:style="{ '--card-color': agent.color }" :style="{ '--card-color': agent.color }"
@click="goToAgent(agent.id)" @click="goToAgent(agent.id)"
> >
@@ -122,6 +273,15 @@ function resolveIcon(iconName: string) {
</div> </div>
</div> </div>
<p class="card-desc">{{ agent.description }}</p> <p class="card-desc">{{ agent.description }}</p>
<div class="agent-runtime">
<span :class="['runtime-dot', statusTone(agent)]"></span>
<span>{{ agent.statusLabel || (agent.isActive ? 'Arbeitet' : 'Bereit') }}</span>
<span v-if="agent.model" class="runtime-model">{{ agent.model }}</span>
</div>
<p class="runtime-detail">{{ statusCopy(agent) }}</p>
<div class="progress-track">
<span :style="{ width: `${agent.progress ?? 0}%`, background: agent.color }"></span>
</div>
<div class="card-tags"> <div class="card-tags">
<span <span
v-for="tag in agent.tags" v-for="tag in agent.tags"
@@ -139,6 +299,10 @@ function resolveIcon(iconName: string) {
</div> </div>
</article> </article>
</div> </div>
<div v-else-if="!loading" class="empty-state">
<h3>Keine Agenten sichtbar</h3>
<p>Mission Control hat aktuell keine Agenten aus dem Backend erhalten. Prüfe Gateway-Erreichbarkeit und Agent-Konfiguration.</p>
</div>
</div> </div>
</template> </template>
@@ -177,6 +341,58 @@ function resolveIcon(iconName: string) {
font-size: 11px; font-size: 11px;
color: #7e8799; color: #7e8799;
} }
.gateway-chip {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 9px;
border: 1px solid var(--line);
border-radius: 7px;
color: #9ba3b5;
font-size: 10px;
}
.gateway-chip.ok {
color: #51d49a;
border-color: rgba(81, 212, 154, .25);
}
.gateway-chip.warn {
color: #e5b05e;
border-color: rgba(229, 176, 94, .28);
}
.gateway-chip.error {
color: #f29b9b;
border-color: rgba(242, 155, 155, .3);
}
.load-error {
margin-bottom: 14px;
color: #e5b05e;
font-size: 11px;
}
.gateway-warning,
.empty-state {
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 11px;
border: 1px solid rgba(229, 176, 94, .24);
background: rgba(229, 176, 94, .08);
color: #f1d7aa;
font-size: 11px;
line-height: 1.5;
}
.empty-state {
border-color: var(--line);
background: rgba(255,255,255,.03);
color: #aab2c3;
}
.empty-state h3 {
margin: 0 0 6px;
font-size: 14px;
color: #e8eaf0;
}
.empty-state p {
margin: 0;
}
/* Agent grid */ /* Agent grid */
.agents-grid { .agents-grid {
@@ -201,6 +417,15 @@ function resolveIcon(iconName: string) {
box-shadow: 0 0 20px color-mix(in srgb, var(--card-color) 10%, transparent); box-shadow: 0 0 20px color-mix(in srgb, var(--card-color) 10%, transparent);
transform: translateY(-2px); transform: translateY(-2px);
} }
.agent-card.status-error {
border-color: rgba(242, 155, 155, .22);
}
.agent-card.status-unsupported {
border-color: rgba(229, 176, 94, .22);
}
.agent-card.status-stale {
border-color: rgba(244, 164, 96, .22);
}
.card-stripe { .card-stripe {
height: 3px; height: 3px;
@@ -256,6 +481,60 @@ function resolveIcon(iconName: string) {
margin: 0 0 10px; margin: 0 0 10px;
flex: 1; flex: 1;
} }
.agent-runtime {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
color: #8a92a5;
font-size: 9.5px;
min-width: 0;
}
.runtime-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #6b7385;
flex-shrink: 0;
}
.runtime-dot.on {
background: #51d49a;
}
.runtime-dot.connected { background: #51d49a; }
.runtime-dot.thinking { background: #79aaff; }
.runtime-dot.blocked { background: #f87171; }
.runtime-dot.stale { background: #f59e0b; }
.runtime-dot.error { background: #f29b9b; }
.runtime-dot.unsupported { background: #e5b05e; }
.runtime-dot.ready { background: #6b7385; }
.runtime-model {
margin-left: auto;
max-width: 46%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
color: #6f788b;
}
.runtime-detail {
margin: 0 0 10px;
min-height: 28px;
color: #7e8799;
font-size: 10px;
line-height: 1.4;
}
.progress-track {
height: 3px;
border-radius: 999px;
background: rgba(255,255,255,.06);
overflow: hidden;
margin-bottom: 10px;
}
.progress-track span {
display: block;
height: 100%;
border-radius: inherit;
}
.card-tags { .card-tags {
display: flex; display: flex;
+3 -1
View File
@@ -264,8 +264,10 @@ function childStatusSummary(taskId: string): string {
} }
function activityHint(task: BoardTask): string { function activityHint(task: BoardTask): string {
const childSummary = childStatusSummary(task.id)
if (childSummary) return childSummary
return task.lastActivityMessage?.trim() return task.lastActivityMessage?.trim()
|| childStatusSummary(task.id)
|| (task.expectedFrom ? `Wartet auf ${expectedFromLabel(task.expectedFrom)}` : 'Noch kein relevanter Progress-Status') || (task.expectedFrom ? `Wartet auf ${expectedFromLabel(task.expectedFrom)}` : 'Noch kein relevanter Progress-Status')
} }
+29 -2
View File
@@ -1,6 +1,6 @@
# Phase 3 # Phase 3
> Letzte Aktualisierung: 2026-06-21 > Letzte Aktualisierung: 2026-07-09
- [ ] Office View - [ ] Office View
- [ ] Kalender - [ ] Kalender
@@ -13,4 +13,31 @@
## Fokus ## Fokus
Parent-Child-Delegation ist im Board umgesetzt. Offene Phase-3-Schwerpunkte sind jetzt Kalender, Office View und spaetere Reporting-/Visualisierungs-Ausbauten. Parent-Child-Delegation ist im Board umgesetzt; Activity-Hints priorisieren sichtbaren Child-Fortschritt vor Parent-Aktivitaet. Das Mission-Control-Programm `df39cd02-b944-41a0-8cfc-400616460ef6` macht Nexus agent-first Mission Control: MCP fuer Agenten, Gateway-Read/Control-Plane und UI fuer Board, Agenten, Crons, Sessions, Live-Thinking und begrenzte owner-only Config-/Approval-Schreibpfade. P0-P5 sind implementiert und lokal verifiziert.
## Mission-Control-Programm
- P0 `9aac2bb0-46dd-47e9-8f48-e39ac66e9918`: Stale-Recovery BackgroundService, `Done`.
- P1 `c56ecd42-90d5-43f9-b21b-60e02ecd4977`: MCP-Fundament fuer Board/Data Plane. Offizielles C# MCP-SDK, streamable-http `/mcp`, Tools als Fassade ueber `ITaskBridgeService`, Worker-Migration auf MCP. Backend-Implementierung und Tests erledigt; Gateway-`mcp.servers`-Eintraege bleiben Produktionskonfiguration und werden nicht aus dem Repo heraus mutiert.
- P2 `b1613607-e417-44ce-a7b8-f90b610b2200`: Read-only Mission Control via GatewayConnector, Gateway-Version-Pinning, Agentenkacheln. Status-Semantik kommt aus dem Backend, Gateway-Versionen melden `matched`, `drift`, `missing`, `unpinned`, `unknown`, `error`; Agentenkacheln zeigen `connected`, `thinking`, `blocked`, `ready`, `stale`, `error`, `unsupported`. Verifiziert und erledigt.
- P3 `53b56788-51cd-4b4e-a1d8-98589c002fac`: Live-Thinking Feed und redigierte Now/Today-Summaries. Nexus-Activity streamt live via SSE-Reconnect; Gateway-Session-History bleibt read-only Fallback ohne direkte Live-Events. Now/Today-Summaries sind deterministisch aus redigierter Activity/History abgeleitet.
- P4 `b7b104d5-21a5-47d0-b5e9-609822354334`: Bao-only Config-Schreibpfad und Approvals mit Backup, Validate, Reload-Check und Audit. Owner-only Config-Writes und Approval-Aktionen, Validierung vor Replace, `.bak`, Audit ohne Content/Secrets und ehrlicher Reload-Status `not_supported` sind umgesetzt. JSON-Validierung ist im Save-Pfad vorbereitet, bleibt aber durch die aktuelle Editable-Allowlist nicht exposed.
- P5 `994c0d32-ec5a-408f-be45-870bf073c596`: Konsolidierung, Doku und API-Grenzen. MCP ist bevorzugter Agentenpfad, `/api/bridge` bleibt interne Kompatibilitaetsflaeche, `/api/dashboard` ist UI/Admin. P2-P4-Grenzen sind dokumentiert: Gateway-History ist Fallback-only, Raw-Feed wird nicht dauerhaft persistiert, Config-Hot-Reload ist `not_supported`, JSON-Validation bleibt ohne Allowlist nicht exposed.
Arbeitsreihenfolge abgeschlossen: P1 -> Review -> P2 -> Review -> P3 -> Review -> P4 -> Review -> P5.
## Abschlusslog
- 2026-06-24: TaskBoardView `activityHint` korrigiert, damit Parent-Tasks mit sichtbaren Child-Tasks zuerst den Child-Fortschritt anzeigen. Verifikation: Frontend-`pnpm typecheck` gruen.
- 2026-07-07: Vier delegated Iris Child-Tasks sequenziell per Nexus API verarbeitet und mit Activity-Hinweis versehen. Verifikation: `GET /api/dashboard/tasks/board` `200`, Counts `offen=1`, `inProgress=0`, `review=11`, `blocked=2`, `done=4`.
- 2026-07-07: Ready-Agent-Result-IDs nach Vorgabe verarbeitet: zwei bereits gesetzte Review-Tasks unveraendert gelassen, zwei Done-Tasks ueber `In progress` mit Result-Hinweis nach `Review` bewegt. Verifikation: `GET /api/dashboard/tasks/board` `200`, Counts `offen=1`, `inProgress=0`, `review=13`, `blocked=2`, `done=2`.
- 2026-07-08: Mission-Control-Programm als Backlog-Parent `df39cd02-b944-41a0-8cfc-400616460ef6` angelegt, PO-Child `81ddae64-e833-4d27-b96c-686c4d64296d` fuer Phasenschnitt erstellt und `3d300184-69fc-4215-bb8b-dc06c5c58f57` per Activity als von P1 MCP-Fundament absorbiert markiert. Verifikation: Bridge-API `create_task`, `create_child_task`, `append_activity` erfolgreich.
- 2026-07-08: Bao-Review-Gate geleert: 13 Review-Tasks mit Bao-Abnahme-Hinweis auf `Done` gesetzt. Verifikation: Bridge-Board `review=0`, `done=15`, `offen=5`, `inProgress=1`, `blocked=2`.
- 2026-07-08: Mission-Control-Programm in P1-P5 geschnitten. P0 ist erledigt, PO-Child `81ddae64-e833-4d27-b96c-686c4d64296d` auf `Done`, P2-P5 als Child-Tasks angelegt und Alt-Task `3d300184-69fc-4215-bb8b-dc06c5c58f57` als in P1 absorbiert geschlossen. Naechster Schritt: P1-Developer-Lauf.
- 2026-07-08: P1 MCP-Fundament implementiert: `ModelContextProtocol.AspNetCore` registriert, `/mcp` gemappt, zehn `nexus_*` Tools als Fassade ueber `ITaskBridgeService`, State-Enum ohne `Delegated`, MCP-Tooltests ergaenzt. Verifikation: `dotnet test backend-tests/Nexus.Api.Tests.csproj` im .NET-SDK-Container gruen, 119 Tests.
- 2026-07-08: P3 Teilfortschritt: Agent-Detailseite zeigt read-only Thinking/Activity-Feed aus Nexus-Activity plus Gateway-Session-History-Fallback. Offen bleiben echter Event-Subscribe mit <2s Sichtbarkeit, Now/Today-Summaries, Retention-Entscheidung und Redaction-Abnahme.
- 2026-07-08: P2-P5 integrierter Slice: Gateway-Info-Endpunkt mit optionalem Version-Pin, dynamische Agentenkacheln, Activity-SSE-Reload fuer Thinking, Gateway-Activity-Redaction, Bao/Owner-only Config-Writes mit `.bak` und `config_audit`, Doku/API-Grenzen aktualisiert.
- 2026-07-09: P2 abgenommen: Gateway-Info unterscheidet Pin-Zustaende (`matched`, `drift`, `missing`, `unpinned`, `unknown`, `error`), Agentenstatus wird backendseitig klassifiziert, Frontend zeigt Gateway-Warnungen, Empty/Error-State und Statusdetails ohne lokale Ratesemantik. Verifikation: `COREPACK_HOME=/tmp/corepack pnpm typecheck` gruen; `dotnet test backend-tests/Nexus.Api.Tests.csproj` im .NET-SDK-Container gruen, 125 Tests.
- 2026-07-09: P3 vervollstaendigt und reviewed: Agent-Detailseite reconnectet auf Dashboard-SSE und filtert live `activity.created` ueber explizite `agentIds`; Now/Today-Summaries tragen `source` plus Timestamp und werden deterministisch aus redigierter Nexus-Activity und Gateway-History gebaut. Rohes Gateway-Feed bleibt unpersistiert, persistierte Activity wird vor Save/Publish redigiert. Review-Fix: `agentIds` werden vor Redaction aus dem Original bestimmt, bereits maskierte Bearer/API-Key-Zeilen bleiben erhalten statt komplett gekuerzt zu werden. Verifikation: `dotnet test backend-tests/Nexus.Api.Tests.csproj` im .NET-SDK-Container gruen, 128 Tests; `COREPACK_HOME=/tmp/corepack pnpm typecheck` gruen.
- 2026-07-09: P4 vervollstaendigt und reviewed: Config-Saves und Approval-Aktionen sind owner-only; Config-Saves validieren vor Replace, behalten `.bak`, geben strukturierte `validation`, `backup` und `reloadCheck`-Daten zurueck und auditieren ohne Inhalt/Secrets. Pending approvals sind im owner-only Task-Board-Flow sichtbar; Approve/Reject schreibt `task_approval_audit`. Reload-Check ist bewusst `not_supported`, weil Workspace-MD-Hot-Reload nicht verfuegbar ist. Verifikation: `dotnet test backend-tests/Nexus.Api.Tests.csproj` im .NET-SDK-Container gruen, 132 Tests; `COREPACK_HOME=/tmp/corepack pnpm typecheck` gruen.
- 2026-07-09: P5 Konsolidierung abgeschlossen: README und Phasenlog dokumentieren MCP/Bridge/Dashboard-Grenzen, P3/P4-Retention-/Reload-/Validation-Limits und owner-only Schreibpfade. Doku-Suche bestaetigt: aktive Agenten-Doku empfiehlt MCP/Bridge statt Dashboard-Curl; verbleibende `/api/dashboard`-Treffer sind UI-Code, Controller-Routen oder historische Changelog-/Phasenbelege. Verifikation: `dotnet test backend-tests/Nexus.Api.Tests.csproj` im .NET-SDK-Container gruen, 132 Tests; `COREPACK_HOME=/tmp/corepack pnpm typecheck` gruen.