diff --git a/backend-tests/AgentServiceTests.cs b/backend-tests/AgentServiceTests.cs index b891197..ad409ea 100644 --- a/backend-tests/AgentServiceTests.cs +++ b/backend-tests/AgentServiceTests.cs @@ -45,6 +45,94 @@ public class AgentServiceTests Assert.Null(agent); } + [Fact] + public async Task GetAllowedAgentIdsAsync_IncludesProductOwnerAndProgrammerFast() + { + var configPath = CreateAgentConfigFile(); + var config = CreateConfiguration(configPath); + var runtime = new FakeRuntime(); + var service = new AgentService(config, runtime); + + var ids = await service.GetAllowedAgentIdsAsync(CancellationToken.None); + + Assert.Contains("product-owner", ids); + Assert.Contains("programmer-fast", ids); + } + + [Fact] + public async Task GetAgentAsync_ProgrammerFast_UsesPrimaryModelAndDeveloperRole() + { + var configPath = CreateAgentConfigFile(); + var config = CreateConfiguration(configPath); + var runtime = new FakeRuntime(); + var service = new AgentService(config, runtime); + + var agent = await service.GetAgentAsync("programmer-fast", CancellationToken.None); + + Assert.NotNull(agent); + Assert.Equal("Developer", agent.Role); + Assert.Equal("openai/gpt-5.3-codex-spark", agent.Model); + } + + [Fact] + public async Task GetAgentAsync_LegacyStringModel_IsSupported() + { + var configPath = CreateAgentConfigFile( + """ + { + "agents": { + "defaults": { + "workspace": "/workspace/default", + "model": "deepseek/deepseek-v4-flash" + }, + "list": [ + { + "id": "iris", + "name": "iris", + "model": "openai/gpt-5.5" + } + ] + } + } + """); + var config = CreateConfiguration(configPath); + var service = new AgentService(config, new FakeRuntime()); + + var agent = await service.GetAgentAsync("iris", CancellationToken.None); + + Assert.NotNull(agent); + Assert.Equal("openai/gpt-5.5", agent!.Model); + } + + [Fact] + public async Task GetAgentAsync_ObjectModel_InheritsStringDefaultModel() + { + var configPath = CreateAgentConfigFile( + """ + { + "agents": { + "defaults": { + "workspace": "/workspace/default", + "model": "openai/gpt-5.5-mini" + }, + "list": [ + { + "id": "reviewer", + "name": "reviewer" + } + ] + } + } + """); + var config = CreateConfiguration(configPath); + var service = new AgentService(config, new FakeRuntime()); + + var agent = await service.GetAgentAsync("reviewer", CancellationToken.None); + + Assert.NotNull(agent); + Assert.Equal("openai/gpt-5.5-mini", agent!.Model); + } + private static IConfiguration CreateConfiguration(string configPath) => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -53,10 +141,10 @@ public class AgentServiceTests }) .Build(); - private static string CreateAgentConfigFile() + private static string CreateAgentConfigFile(string? json = null) { var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json"); - File.WriteAllText(path, + File.WriteAllText(path, json ?? """ { "agents": { @@ -69,19 +157,33 @@ public class AgentServiceTests "list": [ { "id": "iris", - "name": "iris" + "name": "iris", + "model": { "primary": "openai/gpt-5.5" } + }, + { + "id": "product-owner", + "name": "product-owner", + "model": { "primary": "openai/gpt-5.5" } }, { "id": "programmer", - "name": "programmer" + "name": "programmer", + "model": { "primary": "openai/gpt-5.4" } + }, + { + "id": "programmer-fast", + "name": "programmer-fast", + "model": { "primary": "openai/gpt-5.3-codex-spark" } }, { "id": "reviewer", - "name": "reviewer" + "name": "reviewer", + "model": { "primary": "openai/gpt-5.5" } }, { "id": "architekt", - "name": "architekt" + "name": "architekt", + "model": { "primary": "openai/gpt-5.5" } } ] } diff --git a/backend-tests/TaskWorkflowTests.cs b/backend-tests/TaskWorkflowTests.cs new file mode 100644 index 0000000..4dc5b97 --- /dev/null +++ b/backend-tests/TaskWorkflowTests.cs @@ -0,0 +1,548 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Nexus.Api.Controllers; +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 TaskWorkflowTests +{ + [Fact] + public async Task CreateAgentTaskAsync_PreservesConfiguredAssigneeAndBacklogState_WhenPlannedChildTask() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var parent = await fixture.TaskService.CreateDashboardTaskAsync( + "Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None); + + var child = await fixture.TaskService.CreateAgentTaskAsync( + "PO spec", + "Prepare specification", + "iris", + "Medium", + "product-owner", + "programmer-fast", + parent.Id, + startsInProgress: false, + initialState: null, + ct: CancellationToken.None); + + Assert.Equal("Backlog", child.State); + Assert.Equal("product-owner", child.AssignedTo); + Assert.Equal("programmer-fast", child.ExpectedFrom); + Assert.True(child.IsAgentTask); + } + + [Fact] + public async Task GetDashboardTaskByIdAsync_MapsChildDelegationAndActivity() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var parent = await fixture.TaskService.CreateDashboardTaskAsync( + "Parent", null, "iris", "High", "iris", null, CancellationToken.None); + + var child = await fixture.TaskService.CreateAgentTaskAsync( + "Implement", + "Code changes", + "iris", + "High", + "programmer-fast", + "programmer-fast", + parent.Id, + startsInProgress: false, + initialState: null, + ct: CancellationToken.None); + + var dto = await fixture.TaskService.GetDashboardTaskByIdAsync(child.Id, CancellationToken.None); + + Assert.NotNull(dto); + Assert.True(dto!.HasVisibleDelegation); + Assert.NotNull(dto.LastActivityMessage); + Assert.Equal("programmer-fast", dto.AssignedTo); + Assert.Equal("programmer-fast", dto.ExpectedFrom); + } + + [Fact] + public async Task BridgeGetChildTasksAsync_ReturnsMappedActivityAndVisibleDelegation() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var parent = await fixture.TaskService.CreateDashboardTaskAsync( + "Parent", null, "iris", "High", "iris", null, CancellationToken.None); + + await fixture.TaskBridgeService.CreateChildTaskAsync( + parent.Id, + "Review", + "Review implementation", + "iris", + "Medium", + "reviewer", + "reviewer", + startsInProgress: false, + ct: CancellationToken.None); + + var children = await fixture.TaskBridgeService.GetChildTasksAsync(parent.Id, CancellationToken.None); + var child = Assert.Single(children); + + Assert.True(child.HasVisibleDelegation); + Assert.NotNull(child.LastActivityMessage); + Assert.Equal("reviewer", child.AssignedTo); + } + + [Fact] + public async Task GatewayBridgeController_GetBoard_AcceptsProgrammerFastHeader() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var controller = new GatewayBridgeController( + fixture.TaskBridgeService, + fixture.AgentService, + fixture.Configuration, + NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary + { + ["X-Agent-Id"] = "programmer-fast" + }) + } + }; + + var result = await controller.GetBoard(CancellationToken.None); + Assert.IsType(result.Result); + } + + [Fact] + public async Task GatewayBridgeController_GetBoard_AcceptsServiceKeyWithoutConfiguredNexusSystemAgent() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var controller = new GatewayBridgeController( + fixture.TaskBridgeService, + fixture.AgentService, + fixture.Configuration, + NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary + { + ["X-Nexus-Api-Key"] = "test-service-key" + }) + } + }; + + var result = await controller.GetBoard(CancellationToken.None); + Assert.IsType(result.Result); + } + + [Fact] + public async Task DashboardController_GetBoard_AcceptsServiceKeyWithoutJwt() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var controller = new DashboardController( + new FakeDashboardService(), + fixture.TaskService, + fixture.ActivityRepository, + new HttpContextAccessor(), + fixture.AgentService, + fixture.Configuration, + fixture.NotificationService, + fixture.LiveUpdateService) + { + ControllerContext = new ControllerContext + { + HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary + { + ["X-Nexus-Api-Key"] = "test-service-key" + }) + } + }; + + var result = await controller.GetBoard(CancellationToken.None); + Assert.IsType(result.Result); + } + + [Fact] + public async Task TasksController_GetBoard_AcceptsProgrammerFastHeader() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration) + { + ControllerContext = new ControllerContext + { + HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary + { + ["X-Agent-Id"] = "programmer-fast" + }) + } + }; + + var result = await controller.GetBoard(CancellationToken.None); + var httpContext = new DefaultHttpContext(); + await result.ExecuteAsync(httpContext); + + Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode); + } + + [Fact] + public async Task TasksController_ResetStale_Anonymous_IsUnauthorized() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration) + { + ControllerContext = new ControllerContext + { + HttpContext = TaskWorkflowFixture.CreateHttpContext() + } + }; + + var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None); + var httpContext = new DefaultHttpContext(); + await result.ExecuteAsync(httpContext); + + Assert.Equal(StatusCodes.Status401Unauthorized, httpContext.Response.StatusCode); + } + + [Fact] + public async Task TasksController_ResetStale_UnknownAgentHeader_IsForbidden() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration) + { + ControllerContext = new ControllerContext + { + HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary + { + ["X-Agent-Id"] = "unknown-agent" + }) + } + }; + + var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None); + var httpContext = new DefaultHttpContext(); + await result.ExecuteAsync(httpContext); + + Assert.Equal(StatusCodes.Status403Forbidden, httpContext.Response.StatusCode); + } + + [Fact] + public async Task TasksController_ResetStale_OrdinaryJwtUser_IsForbidden() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration) + { + ControllerContext = new ControllerContext + { + HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user")) + } + }; + + var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None); + var httpContext = new DefaultHttpContext(); + await result.ExecuteAsync(httpContext); + + Assert.Equal(StatusCodes.Status403Forbidden, httpContext.Response.StatusCode); + } + + [Fact] + public async Task TasksController_ResetStale_ServiceKey_IsAllowed() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration) + { + ControllerContext = new ControllerContext + { + HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary + { + ["X-Nexus-Api-Key"] = "test-service-key" + }) + } + }; + + var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None); + var httpContext = new DefaultHttpContext(); + await result.ExecuteAsync(httpContext); + + Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode); + } + + [Fact] + public async Task TasksController_ResetStale_IrisHeader_IsAllowed() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration) + { + ControllerContext = new ControllerContext + { + HttpContext = TaskWorkflowFixture.CreateHttpContext(agentId: "iris") + } + }; + + var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None); + var httpContext = new DefaultHttpContext(); + await result.ExecuteAsync(httpContext); + + Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode); + } + + [Fact] + public async Task GatewayBridgeController_GetBoard_OrdinaryJwtUser_IsUnauthorized() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var controller = new GatewayBridgeController( + fixture.TaskBridgeService, + fixture.AgentService, + fixture.Configuration, + NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user")) + } + }; + + var result = await controller.GetBoard(CancellationToken.None); + Assert.IsType(result.Result); + } + + [Fact] + public async Task GatewayBridgeController_GetBoard_AdminJwt_IsAllowed() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + + var controller = new GatewayBridgeController( + fixture.TaskBridgeService, + fixture.AgentService, + fixture.Configuration, + NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("bao", "admin")) + } + }; + + var result = await controller.GetBoard(CancellationToken.None); + Assert.IsType(result.Result); + } + + [Fact] + public async Task CreateChildTaskAsync_TransitionsBacklogParent_WhenCallerIsProgrammerFast() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + fixture.SetCallerAgent("programmer-fast"); + + var parent = await fixture.TaskService.CreateDashboardTaskAsync( + "Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None); + + var result = await fixture.TaskBridgeService.CreateChildTaskAsync( + parent.Id, + "Implement", + "Ship the change", + "programmer-fast", + "Medium", + "programmer-fast", + "programmer-fast", + startsInProgress: false, + ct: CancellationToken.None); + + var updatedParent = await fixture.TaskService.GetByIdAsync(parent.Id, CancellationToken.None); + + Assert.Equal(TaskBridgeOutcome.Success, result.Outcome); + Assert.NotNull(updatedParent); + Assert.Equal("In progress", updatedParent!.State); + } +} + +file sealed class TaskWorkflowFixture : IAsyncDisposable +{ + private readonly NexusDbContext _db; + + private TaskWorkflowFixture( + NexusDbContext db, + IConfiguration configuration, + ITaskRepository taskRepository, + IActivityRepository activityRepository, + INotificationService notificationService, + ILiveUpdateService liveUpdateService, + ITaskService taskService, + ITaskBridgeService taskBridgeService, + IAgentService agentService, + HttpContextAccessor httpContextAccessor) + { + _db = db; + Configuration = configuration; + TaskRepository = taskRepository; + ActivityRepository = activityRepository; + NotificationService = notificationService; + LiveUpdateService = liveUpdateService; + TaskService = taskService; + TaskBridgeService = taskBridgeService; + AgentService = agentService; + HttpContextAccessor = httpContextAccessor; + } + + public IConfiguration Configuration { get; } + public ITaskRepository TaskRepository { get; } + public IActivityRepository ActivityRepository { get; } + public INotificationService NotificationService { get; } + public ILiveUpdateService LiveUpdateService { get; } + public ITaskService TaskService { get; } + public ITaskBridgeService TaskBridgeService { get; } + public IAgentService AgentService { get; } + public HttpContextAccessor HttpContextAccessor { get; } + + public static async Task CreateAsync() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var db = new NexusDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var configPath = CreateAgentConfigFile(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AgentConfigPath"] = configPath, + ["NexusApiKey"] = "test-service-key" + }) + .Build(); + + var agentService = new AgentService(configuration, new FakeRuntime()); + var liveUpdateService = new LiveUpdateService(); + var activityRepository = new ActivityRepository(db); + var taskRepository = new TaskRepository(db); + var notificationService = new NotificationService(db, liveUpdateService); + var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") }; + + var taskService = new TaskService( + taskRepository, + activityRepository, + notificationService, + agentService, + httpContextAccessor, + liveUpdateService); + + var taskBridgeService = new TaskBridgeService( + taskService, + agentService, + activityRepository, + notificationService, + liveUpdateService); + + return new TaskWorkflowFixture( + db, + configuration, + taskRepository, + activityRepository, + notificationService, + liveUpdateService, + taskService, + taskBridgeService, + agentService, + httpContextAccessor); + } + + public static DefaultHttpContext CreateHttpContext( + string? agentId = null, + Dictionary? headers = null, + ClaimsPrincipal? user = null) + { + var httpContext = new DefaultHttpContext(); + if (!string.IsNullOrWhiteSpace(agentId)) + httpContext.Request.Headers["X-Agent-Id"] = agentId; + + if (headers is not null) + { + foreach (var (key, value) in headers) + httpContext.Request.Headers[key] = value; + } + + httpContext.User = user ?? new ClaimsPrincipal(new ClaimsIdentity()); + return httpContext; + } + + public static ClaimsPrincipal CreateUser(string userId, string role) + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim(ClaimTypes.Role, role) + }; + + return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + } + + public void SetCallerAgent(string agentId) + { + HttpContextAccessor.HttpContext = CreateHttpContext(agentId: agentId); + } + + public async ValueTask DisposeAsync() + { + await _db.DisposeAsync(); + } + + private static string CreateAgentConfigFile() + { + var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, + """ + { + "agents": { + "defaults": { + "workspace": "/workspace/default", + "model": { + "primary": "deepseek/deepseek-v4-flash" + } + }, + "list": [ + { "id": "iris", "name": "iris", "model": { "primary": "openai/gpt-5.5" } }, + { "id": "product-owner", "name": "product-owner", "model": { "primary": "openai/gpt-5.5" } }, + { "id": "programmer", "name": "programmer", "model": { "primary": "openai/gpt-5.4" } }, + { "id": "programmer-fast", "name": "programmer-fast", "model": { "primary": "openai/gpt-5.3-codex-spark" } }, + { "id": "reviewer", "name": "reviewer", "model": { "primary": "openai/gpt-5.5" } } + ] + } + } + """); + + return path; + } +} + +file sealed class FakeDashboardService : IDashboardService +{ + public Task GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0)); + public Task> GetAgentsAsync() => Task.FromResult(new List()); + public Task> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List()); + public Task SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null)); + public Task> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List()); + public Task> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List()); + public Task DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored)); + public Task CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored)); + public Task GetAgentModelAsync(string agentId) => Task.FromResult(null); + public Task SetAgentModelAsync(string agentId, string model) => Task.FromResult(false); + public Task> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List()); + public List GetAvailableModels() => []; +} diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs index 493d4c0..60618ab 100644 --- a/backend/Controllers/DashboardController.cs +++ b/backend/Controllers/DashboardController.cs @@ -16,6 +16,8 @@ public class DashboardController( ITaskService taskService, IActivityRepository activityService, IHttpContextAccessor httpContextAccessor, + IAgentService agentService, + IConfiguration configuration, INotificationService notificationService, ILiveUpdateService liveUpdateService) : ControllerBase { @@ -191,9 +193,15 @@ public class DashboardController( // ── Task Board Endpoints ── + [AllowAnonymous] [HttpGet("tasks/board")] - public async Task GetBoard(CancellationToken ct) - => await taskService.GetBoardAsync(ct); + public async Task> GetBoard(CancellationToken ct) + { + if (!await CanReadBoardAsync(ct)) + return Unauthorized(); + + return Ok(await taskService.GetBoardAsync(ct)); + } [HttpGet("live")] public async Task Live( @@ -320,8 +328,17 @@ public class DashboardController( [HttpGet("tasks/{id:guid}/children")] public async Task>> GetChildren(Guid id, CancellationToken ct) { - var children = await taskService.GetChildTasksAsync(id, ct); - return Ok(children.Select(MapToDto).ToList()); + var board = await taskService.GetBoardAsync(ct); + var children = board.Offen + .Concat(board.InProgress) + .Concat(board.Review) + .Concat(board.Blocked) + .Concat(board.Done) + .Where(task => task.ParentTaskId == id) + .OrderByDescending(task => task.UpdatedAt) + .ToList(); + + return Ok(children); } [HttpGet("tasks/{id:guid}")] @@ -401,7 +418,7 @@ public class DashboardController( var task = await taskService.CreateAgentTaskAsync( request.Title, request.Detail, request.Source ?? "iris", request.Priority, request.AssignedTo, request.ExpectedFrom, - request.ParentTaskId, ct); + request.ParentTaskId, request.StartsInProgress, request.InitialState, ct); return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task)); } @@ -415,4 +432,16 @@ public class DashboardController( t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt, t.IsAgentTask, t.ExpectedFrom); + + private async Task CanReadBoardAsync(CancellationToken ct) + { + var allowedAgent = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct); + if (!string.IsNullOrWhiteSpace(allowedAgent)) + return true; + + if (RequestAuthorizationHelper.HasValidServiceKey(HttpContext, configuration)) + return true; + + return User.Identity?.IsAuthenticated == true; + } } diff --git a/backend/Controllers/GatewayBridgeController.cs b/backend/Controllers/GatewayBridgeController.cs index d9a0f44..889c665 100644 --- a/backend/Controllers/GatewayBridgeController.cs +++ b/backend/Controllers/GatewayBridgeController.cs @@ -38,6 +38,7 @@ namespace Nexus.Api.Controllers; public class GatewayBridgeController( ITaskBridgeService bridge, IAgentService agentService, + IConfiguration configuration, ILogger logger) : ControllerBase { private const string ApikeyErrorMessage = @@ -101,6 +102,7 @@ public class GatewayBridgeController( priority: command.Priority ?? "Normal", assignedTo: command.AssignedTo, expectedFrom: command.ExpectedFrom ?? command.AssignedTo, + startsInProgress: command.StartsInProgress, ct: ct); return MapResult(result, "create_child_task"); @@ -232,12 +234,13 @@ public class GatewayBridgeController( private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct) { var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct); + var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds); var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(agentHeader)) { var normalizedHeader = agentHeader.Trim().ToLowerInvariant(); - if (allowedAgentIds.Contains(normalizedHeader)) + if (allowedActorIds.Contains(normalizedHeader)) return (true, normalizedHeader, null); logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback", @@ -248,14 +251,17 @@ public class GatewayBridgeController( if (User.Identity?.IsAuthenticated == true) { var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant(); - if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedAgentIds.Contains(normalizedClaim)) + if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim)) return (true, normalizedClaim, null); - if (User.IsInRole("owner") || User.IsInRole("admin") || User.IsInRole("member")) + // Browser JWT fallback is intentionally restricted to board owners/admins. + // Agent/service traffic should authenticate as an allowed agent or service principal. + if (User.IsInRole("owner") || User.IsInRole("admin")) return (true, "bao", null); } - if (User.IsInRole("Service") && allowedAgentIds.Contains("nexus-system")) + if (RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration) && + allowedActorIds.Contains("nexus-system")) return (true, "nexus-system", null); var unauthorized = Unauthorized(new { error = ApikeyErrorMessage }); @@ -345,7 +351,8 @@ public sealed record BridgeCreateChildTaskCommand( string? Detail = null, string? Priority = null, string? AssignedTo = null, - string? ExpectedFrom = null + string? ExpectedFrom = null, + bool StartsInProgress = false ); public sealed record BridgeUpdateStatusCommand(string State); diff --git a/backend/Controllers/TasksController.cs b/backend/Controllers/TasksController.cs index fb7d7e9..db516a5 100644 --- a/backend/Controllers/TasksController.cs +++ b/backend/Controllers/TasksController.cs @@ -10,7 +10,7 @@ namespace Nexus.Api.Controllers; [Authorize] [ApiController] [Route("api/v1/tasks")] -public class TasksController(ITaskService taskService, IAgentService agentService) : ControllerBase +public class TasksController(ITaskService taskService, IAgentService agentService, IConfiguration configuration) : ControllerBase { [HttpGet] public async Task GetAll(CancellationToken ct) @@ -117,12 +117,12 @@ public class TasksController(ITaskService taskService, IAgentService agentServic /// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr. /// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen. /// + [AllowAnonymous] [HttpGet("board")] public async Task GetBoard(CancellationToken ct) { - // Erfordert mindestens einen identifizierbaren Agent-Aufrufer - var agentHeader = await GetAllowedAgentHeaderAsync(ct); - var isApiKey = HttpContext.User.IsInRole("Service"); + var agentHeader = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct); + var isApiKey = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration); var isAuth = HttpContext.User.Identity?.IsAuthenticated == true; if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth) @@ -136,33 +136,28 @@ public class TasksController(ITaskService taskService, IAgentService agentServic /// Wird vom Iris Autonomous Worker genutzt. /// /// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER - /// X-Nexus-Api-Key / JWT-authenticated user. + /// X-Nexus-Api-Key / Service-Principal ODER owner/admin JWT. /// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen. /// + [AllowAnonymous] [HttpPost("reset-stale")] public async Task ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct) { - var agentHeader = await GetAllowedAgentHeaderAsync(ct); - var isApiKey = HttpContext.User.IsInRole("Service"); - var isAuth = HttpContext.User.Identity?.IsAuthenticated == true; + var agentHeaderResolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(HttpContext, agentService, ct); + var isService = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration); + var isPrivilegedUser = RequestAuthorizationHelper.IsPrivilegedUser(HttpContext); + + var isIris = string.Equals(agentHeaderResolution.AgentId, "iris", StringComparison.OrdinalIgnoreCase); + if (!isIris && !isService && !isPrivilegedUser) + { + // A presented but unrecognized agent header is an invalid credential, not a missing one. + if (HttpContext.User.Identity?.IsAuthenticated == true || agentHeaderResolution.HeaderProvided) + return Results.Forbid(); - // Nur iris, nexus-system (ApiKey) oder JWT-authenticated user - var isIris = string.Equals(agentHeader, "iris", StringComparison.OrdinalIgnoreCase); - if (!isIris && !isApiKey && !isAuth) return Results.Unauthorized(); + } var count = await taskService.ResetStaleAsync(request.StaleHours, ct); return Results.Ok(new ResetStaleResponse(count)); } - - private async Task GetAllowedAgentHeaderAsync(CancellationToken ct) - { - var headerValue = HttpContext.Request.Headers["X-Agent-Id"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(headerValue)) - return null; - - var normalized = headerValue.Trim().ToLowerInvariant(); - var allowed = await agentService.GetAllowedAgentIdsAsync(ct); - return allowed.Contains(normalized) ? normalized : null; - } } diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs index ba28d93..2160a01 100644 --- a/backend/Models/Dashboard.cs +++ b/backend/Models/Dashboard.cs @@ -116,7 +116,9 @@ public sealed record CreateAgentTaskRequest( string? Priority, string? AssignedTo, string? ExpectedFrom, - Guid? ParentTaskId = null + Guid? ParentTaskId = null, + bool StartsInProgress = true, + string? InitialState = null ); public sealed record UpdateDashboardTaskRequest( diff --git a/backend/Services/AgentIdentityCatalog.cs b/backend/Services/AgentIdentityCatalog.cs new file mode 100644 index 0000000..36bd844 --- /dev/null +++ b/backend/Services/AgentIdentityCatalog.cs @@ -0,0 +1,44 @@ +namespace Nexus.Api.Services; + +public static class AgentIdentityCatalog +{ + public static readonly string[] DefaultConfiguredAgentIds = + [ + "main", + "iris", + "product-owner", + "programmer", + "programmer-fast", + "reviewer", + "architekt", + "researcher", + "executor" + ]; + + private static readonly string[] WorkflowActorIds = + [ + "bao", + "nexus-system" + ]; + + public static IReadOnlySet BuildAllowedActorIds(IEnumerable configuredAgentIds) + { + var ids = new HashSet(WorkflowActorIds, StringComparer.OrdinalIgnoreCase); + foreach (var configuredAgentId in configuredAgentIds) + { + if (!string.IsNullOrWhiteSpace(configuredAgentId)) + ids.Add(configuredAgentId.Trim().ToLowerInvariant()); + } + + return ids; + } + + public static string? NormalizeActorId(string? actorId, IReadOnlySet allowedActorIds) + { + if (string.IsNullOrWhiteSpace(actorId)) + return null; + + var normalized = actorId.Trim().ToLowerInvariant(); + return allowedActorIds.Contains(normalized) ? normalized : null; + } +} diff --git a/backend/Services/AgentService.cs b/backend/Services/AgentService.cs index 5a82016..3981c98 100644 --- a/backend/Services/AgentService.cs +++ b/backend/Services/AgentService.cs @@ -20,7 +20,8 @@ public sealed record AgentConfig public string? AgentDir { get; init; } [JsonPropertyName("model")] - public string? Model { get; init; } + [JsonConverter(typeof(AgentModelConfigConverter))] + public AgentModelConfig? Model { get; init; } [JsonPropertyName("identity")] public AgentIdentityConfig? Identity { get; init; } @@ -44,6 +45,60 @@ public sealed record AgentIdentityConfig public string Theme { get; init; } = string.Empty; } +public sealed record AgentModelConfig +{ + [JsonPropertyName("primary")] + public string? Primary { get; init; } +} + +public sealed class AgentModelConfigConverter : JsonConverter +{ + public override AgentModelConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + + if (reader.TokenType == JsonTokenType.String) + { + var primary = reader.GetString(); + return string.IsNullOrWhiteSpace(primary) ? null : new AgentModelConfig { Primary = primary }; + } + + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException("Agent model must be either a string or an object."); + + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + + string? primary = null; + foreach (var property in root.EnumerateObject()) + { + if (!string.Equals(property.Name, "primary", StringComparison.OrdinalIgnoreCase)) + continue; + + primary = property.Value.ValueKind switch + { + JsonValueKind.String => property.Value.GetString(), + JsonValueKind.Null => null, + _ => throw new JsonException("Agent model primary must be a string.") + }; + break; + } + + return new AgentModelConfig { Primary = primary }; + } + + public override void Write(Utf8JsonWriter writer, AgentModelConfig value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + if (!string.IsNullOrWhiteSpace(value.Primary)) + writer.WriteString("primary", value.Primary); + else + writer.WriteNull("primary"); + writer.WriteEndObject(); + } +} + public sealed record AgentInfo( string Id, string Name, @@ -94,7 +149,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run var agents = new List(configs.Count); foreach (var config in configs) { - var model = config.Model ?? "deepseek/deepseek-v4-flash"; + var model = ResolveModel(config); var role = DeriveRole(config.Id); var description = config.Identity?.Theme ?? string.Empty; @@ -141,7 +196,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run Id: config.Id, Name: config.Identity?.Name ?? config.Name ?? config.Id, Role: role, - Model: config.Model ?? "deepseek/deepseek-v4-flash", + Model: ResolveModel(config), Status: runtimeStatus.Status, LastSeen: now, Workspace: config.Workspace, @@ -159,36 +214,43 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run return configs .Where(config => !string.IsNullOrWhiteSpace(config.Id)) .Select(config => config.Id.Trim().ToLowerInvariant()) + .DefaultIfEmpty() + .Where(id => !string.IsNullOrWhiteSpace(id)) .ToHashSet(StringComparer.OrdinalIgnoreCase); } private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch { "iris" => "Orchestrator", + "product-owner" => "Product Owner", "programmer" => "Developer", + "programmer-fast" => "Developer", "reviewer" => "Reviewer", "architekt" => "Architect", "main" => "Assistant", _ => "Custom" }; + private static string ResolveModel(AgentConfig config) + => config.Model?.Primary ?? "deepseek/deepseek-v4-flash"; + private async Task> LoadAgentConfigsAsync(CancellationToken cancellationToken) { var path = configuration.GetValue("AgentConfigPath") ?? "/home/node/.openclaw/openclaw.json"; if (!File.Exists(path)) - return Array.Empty(); + return BuildFallbackConfigs(); var json = await File.ReadAllTextAsync(path, cancellationToken); using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true }); var root = document.RootElement; if (!root.TryGetProperty("agents", out var agentsElement)) - return Array.Empty(); + return BuildFallbackConfigs(); if (!agentsElement.TryGetProperty("list", out var listElement)) - return Array.Empty(); + return BuildFallbackConfigs(); var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement) ? JsonSerializer.Deserialize(defaultsElement.GetRawText(), JsonOptions) @@ -204,29 +266,35 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run // Inherit defaults for missing fields if (string.IsNullOrWhiteSpace(config.Name)) config = config with { Name = config.Id }; - if (string.IsNullOrWhiteSpace(config.Model) && defaults?.Model?.Primary is not null) - config = config with { Model = defaults.Model.Primary }; + if (string.IsNullOrWhiteSpace(config.Model?.Primary) && defaults?.Model?.Primary is not null) + config = config with { Model = new AgentModelConfig { Primary = defaults.Model.Primary } }; if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null) config = config with { Workspace = defaults.Workspace }; configs.Add(config); } - return configs.AsReadOnly(); + return configs.Count > 0 ? configs.AsReadOnly() : BuildFallbackConfigs(); } + private static IReadOnlyList BuildFallbackConfigs() + => AgentIdentityCatalog.DefaultConfiguredAgentIds + .Select(id => new AgentConfig + { + Id = id, + Name = id, + Model = new AgentModelConfig { Primary = "deepseek/deepseek-v4-flash" } + }) + .ToList() + .AsReadOnly(); + private sealed record AgentDefaults { [JsonPropertyName("workspace")] public string? Workspace { get; init; } [JsonPropertyName("model")] - public AgentDefaultModel? Model { get; init; } - } - - private sealed record AgentDefaultModel - { - [JsonPropertyName("primary")] - public string? Primary { get; init; } + [JsonConverter(typeof(AgentModelConfigConverter))] + public AgentModelConfig? Model { get; init; } } } diff --git a/backend/Services/ITaskBridgeService.cs b/backend/Services/ITaskBridgeService.cs index 164b824..1e0f033 100644 --- a/backend/Services/ITaskBridgeService.cs +++ b/backend/Services/ITaskBridgeService.cs @@ -42,6 +42,7 @@ public interface ITaskBridgeService string? priority = "Normal", string? assignedTo = null, string? expectedFrom = null, + bool startsInProgress = false, CancellationToken ct = default); /// diff --git a/backend/Services/ITaskService.cs b/backend/Services/ITaskService.cs index 84abacf..04ad940 100644 --- a/backend/Services/ITaskService.cs +++ b/backend/Services/ITaskService.cs @@ -23,9 +23,10 @@ public interface ITaskService // Dashboard-facing task operations Task> GetOpenAsync(CancellationToken ct = default); Task CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default); - Task CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default); + Task CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = null, CancellationToken ct = default); Task UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default); Task UpdateStatusAsync(Guid id, string status, CancellationToken ct = default); + Task StartCoordinationAsync(Guid id, CancellationToken ct = default); Task CompleteViaQueueAsync(Guid id, CancellationToken ct = default); Task CyclePriorityAsync(Guid id, CancellationToken ct = default); diff --git a/backend/Services/RequestAuthorizationHelper.cs b/backend/Services/RequestAuthorizationHelper.cs new file mode 100644 index 0000000..807584c --- /dev/null +++ b/backend/Services/RequestAuthorizationHelper.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Primitives; + +namespace Nexus.Api.Services; + +public static class RequestAuthorizationHelper +{ + public sealed record AgentHeaderResolution(string? AgentId, bool HeaderProvided, bool IsRecognized); + + public static bool IsAuthenticatedService(HttpContext httpContext, IConfiguration configuration) => + httpContext.User.IsInRole("Service") || HasValidServiceKey(httpContext, configuration); + + public static bool IsPrivilegedUser(HttpContext httpContext) => + httpContext.User.Identity?.IsAuthenticated == true && + (httpContext.User.IsInRole("owner") || httpContext.User.IsInRole("admin")); + + public static async Task ResolveAllowedAgentHeaderAsync( + HttpContext httpContext, + IAgentService agentService, + CancellationToken ct) + => (await ResolveAgentHeaderAsync(httpContext, agentService, ct)).AgentId; + + public static async Task ResolveAgentHeaderAsync( + HttpContext httpContext, + IAgentService agentService, + CancellationToken ct) + { + var headerValue = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(headerValue)) + return new AgentHeaderResolution(null, HeaderProvided: false, IsRecognized: false); + + var allowed = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct)); + var normalized = AgentIdentityCatalog.NormalizeActorId(headerValue, allowed); + return new AgentHeaderResolution( + normalized, + HeaderProvided: true, + IsRecognized: normalized is not null); + } + + public static bool HasValidServiceKey(HttpContext httpContext, IConfiguration configuration) + { + var configuredApiKey = configuration["NexusApiKey"]; + if (string.IsNullOrWhiteSpace(configuredApiKey)) + return false; + + if (!httpContext.Request.Headers.TryGetValue("X-Nexus-Api-Key", out StringValues providedKey)) + return false; + + return string.Equals(configuredApiKey, providedKey.FirstOrDefault(), StringComparison.Ordinal); + } +} diff --git a/backend/Services/TaskBridgeService.cs b/backend/Services/TaskBridgeService.cs index 57032ae..4ad896c 100644 --- a/backend/Services/TaskBridgeService.cs +++ b/backend/Services/TaskBridgeService.cs @@ -15,6 +15,7 @@ namespace Nexus.Api.Services; /// public sealed class TaskBridgeService( ITaskService taskService, + IAgentService agentService, IActivityRepository activityRepo, INotificationService notificationService, ILiveUpdateService liveUpdateService) : ITaskBridgeService @@ -37,12 +38,11 @@ public sealed class TaskBridgeService( return Error(TaskBridgeOutcome.ValidationError, "Title is required."); var normalizedSource = NormalizeSource(source); - var normalizedAssignee = NormalizeAssignedTo(assignedTo); var task = await taskService.CreateDashboardTaskAsync( - title.Trim(), detail?.Trim(), normalizedSource, priority, normalizedAssignee, parentTaskId: null, ct); + title.Trim(), detail?.Trim(), normalizedSource, priority, assignedTo, parentTaskId: null, ct); - var dto = MapToDto(task); + var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task); return Success(dto); } @@ -56,6 +56,7 @@ public sealed class TaskBridgeService( string? priority = "Normal", string? assignedTo = null, string? expectedFrom = null, + bool startsInProgress = false, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(title)) @@ -66,19 +67,23 @@ public sealed class TaskBridgeService( if (parent is null) return Error(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found."); - var normalizedAssignee = NormalizeAssignedTo(assignedTo); - var task = await taskService.CreateAgentTaskAsync( title.Trim(), detail?.Trim(), NormalizeSource(source), - priority, normalizedAssignee, expectedFrom, parentTaskId, ct); + priority, assignedTo, expectedFrom, parentTaskId, startsInProgress, null, ct); // If parent was in Backlog, move it to InProgress (coordination starts) if (string.Equals(parent.State, "Backlog", StringComparison.OrdinalIgnoreCase)) { - await taskService.UpdateStatusAsync(parentTaskId, "In progress", ct); + var parentTransition = await taskService.StartCoordinationAsync(parentTaskId, ct); + if (parentTransition.Outcome != TaskOperationOutcome.Success) + { + return Error( + TaskBridgeOutcome.InvalidState, + $"Parent task {parentTaskId} could not be moved to In progress for coordination."); + } } - var dto = MapToDto(task); + var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task); return Success(dto); } @@ -107,7 +112,7 @@ public sealed class TaskBridgeService( if (result.Outcome != TaskOperationOutcome.Success) return Error(TaskBridgeOutcome.InvalidState, "Status update rejected."); - var dto = MapToDto(result.Task!); + var dto = await taskService.GetDashboardTaskByIdAsync(result.Task!.Id, ct) ?? MapToDto(result.Task); return Success(dto); } @@ -157,7 +162,10 @@ public sealed class TaskBridgeService( if (task is null) return Error(TaskBridgeOutcome.NotFound, $"Task {taskId} not found."); - var normalizedTarget = targetAgent.Trim().ToLowerInvariant(); + var normalizedTarget = await NormalizeActorAsync(targetAgent, ct); + if (normalizedTarget is null) + return Error(TaskBridgeOutcome.ValidationError, $"Unknown target agent '{targetAgent}'."); + var handoffNote = string.IsNullOrWhiteSpace(note) ? $"Handoff → {normalizedTarget}" : $"Handoff → {normalizedTarget}: {note.Trim()}"; @@ -186,7 +194,7 @@ public sealed class TaskBridgeService( task.Id, ct); - var dto = MapToDto(task); + var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task); return Success(dto); } @@ -207,8 +215,11 @@ public sealed class TaskBridgeService( public async Task> GetChildTasksAsync( Guid parentTaskId, CancellationToken ct = default) { - var children = await taskService.GetChildTasksAsync(parentTaskId, ct); - return children.Select(MapToDto).ToList(); + var board = await taskService.GetBoardAsync(ct); + return FlattenBoard(board) + .Where(task => task.ParentTaskId == parentTaskId) + .OrderByDescending(task => task.UpdatedAt) + .ToList(); } public async Task> GetTaskActivityAsync( @@ -233,14 +244,19 @@ public sealed class TaskBridgeService( private static string NormalizeSource(string? source) => string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant(); - private static string? NormalizeAssignedTo(string? assignedTo) + private async Task NormalizeActorAsync(string? actorId, CancellationToken ct) { - if (string.IsNullOrWhiteSpace(assignedTo)) return null; - var valid = new HashSet { "bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor" }; - var lower = assignedTo.Trim().ToLowerInvariant(); - return valid.Contains(lower) ? lower : null; + var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct)); + return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors); } + private static IEnumerable FlattenBoard(BoardResponse board) + => board.Offen + .Concat(board.InProgress) + .Concat(board.Review) + .Concat(board.Blocked) + .Concat(board.Done); + private static DashboardTaskDto MapToDto(WorkTask t) => new( t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt, diff --git a/backend/Services/TaskService.cs b/backend/Services/TaskService.cs index c1a4072..87cf138 100644 --- a/backend/Services/TaskService.cs +++ b/backend/Services/TaskService.cs @@ -9,12 +9,10 @@ public sealed class TaskService( ITaskRepository taskRepo, IActivityRepository activityRepo, INotificationService notificationService, + IAgentService agentService, IHttpContextAccessor httpContextAccessor, ILiveUpdateService liveUpdateService) : ITaskService { - private static readonly HashSet ValidAssignees = - ["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"]; - public async Task> GetAllAsync(CancellationToken ct = default) => await taskRepo.GetAllAsync(ct); @@ -90,12 +88,7 @@ public sealed class TaskService( if (!TaskStateHelper.CanChangeState(caller, task)) return new TaskOperationResult(TaskOperationOutcome.InvalidState); - task.State = canonical; - await taskRepo.UpdateAsync(task, ct); - await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}", TaskId = task.Id }, ct); - await CreateStatusChangeNotificationsAsync(task, canonical, ct); - await PublishBoardSnapshotAsync(ct); - return new TaskOperationResult(TaskOperationOutcome.Success, task); + return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task {task.Title} moved to {canonical}", ct); } public async Task UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default) @@ -204,7 +197,7 @@ public sealed class TaskService( } var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant(); - var normalizedAssignee = ValidateAssignedTo(assignedTo); + var normalizedAssignee = await NormalizeActorAsync(assignedTo, ct); var isVisibleDelegation = parentTaskId.HasValue; var task = new WorkTask @@ -250,14 +243,14 @@ public sealed class TaskService( public async Task CreateAgentTaskAsync( string title, string? detail, string? source, string? priority, - string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default) + string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = null, CancellationToken ct = default) { - var normalizedExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant(); + var normalizedExpectedFrom = await NormalizeActorAsync(expectedFrom, ct); var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct); task.IsAgentTask = true; task.ExpectedFrom = normalizedExpectedFrom; - task.State = TaskStateHelper.ToStateString(TaskState.InProgress); + task.State = ResolveInitialAgentTaskState(startsInProgress, initialState); await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent @@ -322,7 +315,7 @@ public sealed class TaskService( } if (assignedTo is not null) { - var validated = ValidateAssignedTo(assignedTo); + var validated = await NormalizeActorAsync(assignedTo, ct); if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase)) { changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}"); @@ -373,12 +366,24 @@ public sealed class TaskService( return new TaskOperationResult(TaskOperationOutcome.InvalidState); var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase)); - task.State = canonical; - await taskRepo.UpdateAsync(task, ct); - await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}", TaskId = task.Id }, ct); - await CreateStatusChangeNotificationsAsync(task, canonical, ct); - await PublishBoardSnapshotAsync(ct); - return new TaskOperationResult(TaskOperationOutcome.Success, task); + return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", null, ct); + } + + public async Task StartCoordinationAsync(Guid id, CancellationToken ct = default) + { + var task = await taskRepo.GetByIdAsync(id, ct); + if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); + + if (!string.Equals(task.State, "Backlog", StringComparison.OrdinalIgnoreCase)) + return new TaskOperationResult(TaskOperationOutcome.Success, task); + + return await UpdateTaskStatusInternalAsync( + task, + canonical: TaskStateHelper.ToStateString(TaskState.InProgress), + actor: "nexus-system", + activityType: "delegation", + activityMessage: $"Task \"{task.Title}\" → In progress (coordination started by child-task creation)", + ct: ct); } public async Task CompleteViaQueueAsync(Guid id, CancellationToken ct = default) @@ -481,12 +486,7 @@ public sealed class TaskService( if (!TaskStateHelper.CanChangeState(caller, task)) return new TaskOperationResult(TaskOperationOutcome.InvalidState); - task.State = canonical; - await taskRepo.UpdateAsync(task, ct); - await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}", TaskId = task.Id }, ct); - await CreateStatusChangeNotificationsAsync(task, canonical, ct); - await PublishBoardSnapshotAsync(ct); - return new TaskOperationResult(TaskOperationOutcome.Success, task); + return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct); } public Task ResetStaleAsync(int staleHours, CancellationToken ct = default) @@ -577,11 +577,25 @@ public sealed class TaskService( t.ParentTaskId.HasValue || t.IsAgentTask); } - private static string? ValidateAssignedTo(string? assignedTo) + private async Task NormalizeActorAsync(string? actorId, CancellationToken ct) { - if (string.IsNullOrWhiteSpace(assignedTo)) return null; - var lower = assignedTo.Trim().ToLowerInvariant(); - return ValidAssignees.Contains(lower) ? lower : null; + var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct)); + return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors); + } + + private static string ResolveInitialAgentTaskState(bool startsInProgress, string? initialState) + { + if (!string.IsNullOrWhiteSpace(initialState)) + { + var canonical = TaskStateHelper.AllStates.FirstOrDefault(state => + state.Equals(initialState, StringComparison.OrdinalIgnoreCase)); + if (canonical is not null) + return canonical; + } + + return startsInProgress + ? TaskStateHelper.ToStateString(TaskState.InProgress) + : TaskStateHelper.ToStateString(TaskState.Backlog); } private string ResolveCaller() @@ -598,10 +612,29 @@ public sealed class TaskService( return nameClaim?.ToLowerInvariant() ?? ""; } - private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct) + private async Task UpdateTaskStatusInternalAsync( + WorkTask task, + string canonical, + string actor, + string activityType, + string? activityMessage, + CancellationToken ct) { - var caller = ResolveCaller(); + task.State = canonical; + await taskRepo.UpdateAsync(task, ct); + await activityRepo.AddAsync(new ActivityEvent + { + Type = activityType, + Message = activityMessage ?? $"Task \"{task.Title}\" → {canonical}", + TaskId = task.Id + }, ct); + await CreateStatusChangeNotificationsAsync(task, canonical, actor, ct); + await PublishBoardSnapshotAsync(ct); + return new TaskOperationResult(TaskOperationOutcome.Success, task); + } + private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, string caller, CancellationToken ct) + { if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase)) { await notificationService.CreateAsync( diff --git a/docs/openclaw-task-board-flow.md b/docs/openclaw-task-board-flow.md index 164b0de..0dabc65 100644 --- a/docs/openclaw-task-board-flow.md +++ b/docs/openclaw-task-board-flow.md @@ -266,8 +266,10 @@ Fertige Hauptaufgaben gehen erst in **Review**, dann nach Bao-Entscheid auf **Do **„Nexus Taskflow auf Parent-/Child-Modell umstellen“** — Owner: `iris` ### Mögliche Child-Tasks -- **Backend-State-Handling anpassen** — Owner: `developer` -- **Frontend-Board-Spalten und Labels anpassen** — Owner: `developer` +- **PO-Spezifikation und Akzeptanzkriterien ausarbeiten** — Owner: `product-owner` +- **Schnelle Voranalyse / kleiner Patch** — Owner: `programmer-fast` +- **Backend-State-Handling anpassen** — Owner: `programmer` +- **Frontend-Board-Spalten und Labels anpassen** — Owner: `programmer` - **Workflow verifizieren / Regression prüfen** — Owner: `reviewer` - **Deploy-/Runtime-Auswirkung prüfen** — Owner: `architekt` @@ -309,6 +311,7 @@ Wenn Iris unsicher ist, ob sie eine Child-Task anlegen soll, gilt: - `parentTaskId` verknüpft Child-Tasks mit der Parent-Task - `AssignedTo` zeigt den operativen Owner +- Child-Tasks dürfen geplant in `Backlog` erstellt werden; nur aktiv gestartete Delegationen beginnen direkt in `In progress` - Agentenstatus und Boardstatus dürfen sich ergänzen, aber nicht widersprechen - Board-Spalten und API-State-Mapping müssen das Parent-/Child-Modell sauber abbilden - UI und Doku müssen dieselbe Sprache sprechen diff --git a/frontend/src/constants/agentPool.ts b/frontend/src/constants/agentPool.ts index 3144760..0d33f57 100644 --- a/frontend/src/constants/agentPool.ts +++ b/frontend/src/constants/agentPool.ts @@ -1,5 +1,24 @@ import type { AgentNodeData } from '../types/agentNode' +export const TASK_AGENT_OPTIONS = [ + { id: '', label: 'Nicht zugewiesen' }, + { id: 'bao', label: '👤 Bao' }, + { id: 'iris', label: '🤖 Iris' }, + { id: 'product-owner', label: '📋 Product Owner' }, + { id: 'programmer', label: '🛠 Programmer' }, + { id: 'programmer-fast', label: '⚡ Programmer Fast' }, + { id: 'reviewer', label: '🔎 Reviewer' }, + { id: 'architekt', label: '🏛 Architekt' }, + { id: 'researcher', label: '🔬 Researcher' }, + { id: 'executor', label: '🚀 Executor' }, +] as const + +export const TASK_AGENT_LABELS: Record = Object.fromEntries( + TASK_AGENT_OPTIONS + .filter(option => option.id) + .map(option => [option.id, option.label]) +) as Record + export const EXTRA_AGENT_POOL: AgentNodeData[] = [ { id: 'qa', diff --git a/frontend/src/mappers/agentMapper.ts b/frontend/src/mappers/agentMapper.ts index 8a134ce..01421a0 100644 --- a/frontend/src/mappers/agentMapper.ts +++ b/frontend/src/mappers/agentMapper.ts @@ -17,7 +17,9 @@ interface CatalogEntry { const AGENT_CATALOG: Record = { iris: { elapsed: '--', think: null, next: 'Standby' }, + 'product-owner': { elapsed: '--', think: null, next: 'Standby' }, programmer: { elapsed: '--', think: null, next: 'Standby' }, + 'programmer-fast': { elapsed: '--', think: null, next: 'Standby' }, developer: { elapsed: '--', think: null, next: 'Standby' }, architekt: { elapsed: '--', think: null, next: 'Standby' }, reviewer: { elapsed: '--', think: null, next: 'Standby' }, @@ -33,7 +35,9 @@ function resolveStatus(isActive: boolean, currentTask: string | null): AgentNode function resolveAvatar(id: string, name: string): string { if (id === 'iris') return 'IR' + if (id === 'product-owner') return 'PO' if (id === 'programmer' || id === 'developer') return '' + if (id === 'programmer-fast') return 'PF' return name.slice(0, 2).toUpperCase() } diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index fbd1899..6e715eb 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -29,6 +29,10 @@ export interface DashboardTaskDto { expectedFrom?: string | null lastActivityMessage?: string | null lastActivityAt?: string | null + childTasks?: DashboardTaskDto[] | null + childTaskCount?: number + openChildTaskCount?: number + hasVisibleDelegation?: boolean } export interface BoardGroup { @@ -319,6 +323,8 @@ export const useTaskStore = defineStore('tasks', { assignedTo?: string expectedFrom?: string parentTaskId?: string | null + startsInProgress?: boolean + initialState?: string | null }) { try { const res = await apiFetch('/api/dashboard/tasks/agent', { @@ -331,6 +337,8 @@ export const useTaskStore = defineStore('tasks', { assignedTo: data.assignedTo ?? null, expectedFrom: data.expectedFrom ?? null, parentTaskId: data.parentTaskId ?? null, + startsInProgress: data.startsInProgress ?? true, + initialState: data.initialState ?? null, }), }) if (!res.ok) throw new Error(`HTTP ${res.status}`) diff --git a/frontend/src/views/TaskBoardView.vue b/frontend/src/views/TaskBoardView.vue index 0f54cc8..8cb0355 100644 --- a/frontend/src/views/TaskBoardView.vue +++ b/frontend/src/views/TaskBoardView.vue @@ -17,7 +17,8 @@ import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, A import { useRouter } from 'vue-router' import { useAuthStore } from '../stores/auth' import { useTaskStore } from '../stores/tasks' -import { useLiveSyncStore } from '../stores/liveSync' +import { useLiveSyncStore } from '../stores/live-sync' +import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool' type BoardTask = ReturnType[number] @@ -222,16 +223,7 @@ const liveModeClass = computed(() => `live-pill-${liveSyncStore.connectionHealth function expectedFromLabel(expected: string | null | undefined): string { if (!expected) return '' - const map: Record = { - 'bao': '👤 Bao', - 'iris': '🤖 Iris', - 'programmer': '🛠 Programmer', - 'reviewer': '🔎 Reviewer', - 'architekt': '🏛 Architekt', - 'researcher': '🔬 Researcher', - 'executor': '⚡ Executor', - } - return map[expected.toLowerCase()] ?? expected + return TASK_AGENT_LABELS[expected.toLowerCase()] ?? expected } function hoursSince(dateStr: string): number { @@ -827,13 +819,13 @@ onUnmounted(() => {
@@ -951,14 +943,9 @@ onUnmounted(() => {