feat: complete task board workflow gates
CI - Build & Test / Backend (.NET) (push) Failing after 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 20s
CI - Build & Test / Security Check (push) Successful in 3s

This commit is contained in:
2026-06-24 01:22:13 +02:00
parent 68b428e411
commit 95495a8332
19 changed files with 1064 additions and 144 deletions
+108 -6
View File
@@ -45,6 +45,94 @@ public class AgentServiceTests
Assert.Null(agent); 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) private static IConfiguration CreateConfiguration(string configPath)
=> new ConfigurationBuilder() => new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> .AddInMemoryCollection(new Dictionary<string, string?>
@@ -53,10 +141,10 @@ public class AgentServiceTests
}) })
.Build(); .Build();
private static string CreateAgentConfigFile() private static string CreateAgentConfigFile(string? json = null)
{ {
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json"); var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path, File.WriteAllText(path, json ??
""" """
{ {
"agents": { "agents": {
@@ -69,19 +157,33 @@ public class AgentServiceTests
"list": [ "list": [
{ {
"id": "iris", "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", "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", "id": "reviewer",
"name": "reviewer" "name": "reviewer",
"model": { "primary": "openai/gpt-5.5" }
}, },
{ {
"id": "architekt", "id": "architekt",
"name": "architekt" "name": "architekt",
"model": { "primary": "openai/gpt-5.5" }
} }
] ]
} }
+548
View File
@@ -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<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Agent-Id"] = "programmer-fast"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(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<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(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<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(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<string, string>
{
["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<string, string>
{
["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<string, string>
{
["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<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<UnauthorizedObjectResult>(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<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("bao", "admin"))
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(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<TaskWorkflowFixture> CreateAsync()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var configPath = CreateAgentConfigFile();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<string, string>? 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<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<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() => [];
}
+34 -5
View File
@@ -16,6 +16,8 @@ public class DashboardController(
ITaskService taskService, ITaskService taskService,
IActivityRepository activityService, IActivityRepository activityService,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
IAgentService agentService,
IConfiguration configuration,
INotificationService notificationService, INotificationService notificationService,
ILiveUpdateService liveUpdateService) : ControllerBase ILiveUpdateService liveUpdateService) : ControllerBase
{ {
@@ -191,9 +193,15 @@ public class DashboardController(
// ── Task Board Endpoints ── // ── Task Board Endpoints ──
[AllowAnonymous]
[HttpGet("tasks/board")] [HttpGet("tasks/board")]
public async Task<BoardResponse> GetBoard(CancellationToken ct) public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
=> await taskService.GetBoardAsync(ct); {
if (!await CanReadBoardAsync(ct))
return Unauthorized();
return Ok(await taskService.GetBoardAsync(ct));
}
[HttpGet("live")] [HttpGet("live")]
public async Task Live( public async Task Live(
@@ -320,8 +328,17 @@ public class DashboardController(
[HttpGet("tasks/{id:guid}/children")] [HttpGet("tasks/{id:guid}/children")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct) public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
{ {
var children = await taskService.GetChildTasksAsync(id, ct); var board = await taskService.GetBoardAsync(ct);
return Ok(children.Select(MapToDto).ToList()); 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}")] [HttpGet("tasks/{id:guid}")]
@@ -401,7 +418,7 @@ public class DashboardController(
var task = await taskService.CreateAgentTaskAsync( var task = await taskService.CreateAgentTaskAsync(
request.Title, request.Detail, request.Source ?? "iris", request.Title, request.Detail, request.Source ?? "iris",
request.Priority, request.AssignedTo, request.ExpectedFrom, 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)); 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.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt, t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom); t.IsAgentTask, t.ExpectedFrom);
private async Task<bool> 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;
}
} }
+12 -5
View File
@@ -38,6 +38,7 @@ namespace Nexus.Api.Controllers;
public class GatewayBridgeController( public class GatewayBridgeController(
ITaskBridgeService bridge, ITaskBridgeService bridge,
IAgentService agentService, IAgentService agentService,
IConfiguration configuration,
ILogger<GatewayBridgeController> logger) : ControllerBase ILogger<GatewayBridgeController> logger) : ControllerBase
{ {
private const string ApikeyErrorMessage = private const string ApikeyErrorMessage =
@@ -101,6 +102,7 @@ public class GatewayBridgeController(
priority: command.Priority ?? "Normal", priority: command.Priority ?? "Normal",
assignedTo: command.AssignedTo, assignedTo: command.AssignedTo,
expectedFrom: command.ExpectedFrom ?? command.AssignedTo, expectedFrom: command.ExpectedFrom ?? command.AssignedTo,
startsInProgress: command.StartsInProgress,
ct: ct); ct: ct);
return MapResult(result, "create_child_task"); 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) private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct)
{ {
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct); var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault(); var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader)) if (!string.IsNullOrWhiteSpace(agentHeader))
{ {
var normalizedHeader = agentHeader.Trim().ToLowerInvariant(); var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
if (allowedAgentIds.Contains(normalizedHeader)) if (allowedActorIds.Contains(normalizedHeader))
return (true, normalizedHeader, null); return (true, normalizedHeader, null);
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback", 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) if (User.Identity?.IsAuthenticated == true)
{ {
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant(); 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); 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); 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); return (true, "nexus-system", null);
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage }); var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
@@ -345,7 +351,8 @@ public sealed record BridgeCreateChildTaskCommand(
string? Detail = null, string? Detail = null,
string? Priority = null, string? Priority = null,
string? AssignedTo = null, string? AssignedTo = null,
string? ExpectedFrom = null string? ExpectedFrom = null,
bool StartsInProgress = false
); );
public sealed record BridgeUpdateStatusCommand(string State); public sealed record BridgeUpdateStatusCommand(string State);
+17 -22
View File
@@ -10,7 +10,7 @@ namespace Nexus.Api.Controllers;
[Authorize] [Authorize]
[ApiController] [ApiController]
[Route("api/v1/tasks")] [Route("api/v1/tasks")]
public class TasksController(ITaskService taskService, IAgentService agentService) : ControllerBase public class TasksController(ITaskService taskService, IAgentService agentService, IConfiguration configuration) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll(CancellationToken ct) public async Task<IResult> GetAll(CancellationToken ct)
@@ -117,12 +117,12 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
/// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr. /// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr.
/// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen. /// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen.
/// </summary> /// </summary>
[AllowAnonymous]
[HttpGet("board")] [HttpGet("board")]
public async Task<IResult> GetBoard(CancellationToken ct) public async Task<IResult> GetBoard(CancellationToken ct)
{ {
// Erfordert mindestens einen identifizierbaren Agent-Aufrufer var agentHeader = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
var agentHeader = await GetAllowedAgentHeaderAsync(ct); var isApiKey = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
var isApiKey = HttpContext.User.IsInRole("Service");
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true; var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth) if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth)
@@ -136,33 +136,28 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
/// Wird vom Iris Autonomous Worker genutzt. /// Wird vom Iris Autonomous Worker genutzt.
/// ///
/// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER /// 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. /// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen.
/// </summary> /// </summary>
[AllowAnonymous]
[HttpPost("reset-stale")] [HttpPost("reset-stale")]
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct) public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
{ {
var agentHeader = await GetAllowedAgentHeaderAsync(ct); var agentHeaderResolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(HttpContext, agentService, ct);
var isApiKey = HttpContext.User.IsInRole("Service"); var isService = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true; 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(); return Results.Unauthorized();
}
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<string?> 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;
}
} }
+3 -1
View File
@@ -116,7 +116,9 @@ public sealed record CreateAgentTaskRequest(
string? Priority, string? Priority,
string? AssignedTo, string? AssignedTo,
string? ExpectedFrom, string? ExpectedFrom,
Guid? ParentTaskId = null Guid? ParentTaskId = null,
bool StartsInProgress = true,
string? InitialState = null
); );
public sealed record UpdateDashboardTaskRequest( public sealed record UpdateDashboardTaskRequest(
+44
View File
@@ -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<string> BuildAllowedActorIds(IEnumerable<string> configuredAgentIds)
{
var ids = new HashSet<string>(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<string> allowedActorIds)
{
if (string.IsNullOrWhiteSpace(actorId))
return null;
var normalized = actorId.Trim().ToLowerInvariant();
return allowedActorIds.Contains(normalized) ? normalized : null;
}
}
+84 -16
View File
@@ -20,7 +20,8 @@ public sealed record AgentConfig
public string? AgentDir { get; init; } public string? AgentDir { get; init; }
[JsonPropertyName("model")] [JsonPropertyName("model")]
public string? Model { get; init; } [JsonConverter(typeof(AgentModelConfigConverter))]
public AgentModelConfig? Model { get; init; }
[JsonPropertyName("identity")] [JsonPropertyName("identity")]
public AgentIdentityConfig? Identity { get; init; } public AgentIdentityConfig? Identity { get; init; }
@@ -44,6 +45,60 @@ public sealed record AgentIdentityConfig
public string Theme { get; init; } = string.Empty; public string Theme { get; init; } = string.Empty;
} }
public sealed record AgentModelConfig
{
[JsonPropertyName("primary")]
public string? Primary { get; init; }
}
public sealed class AgentModelConfigConverter : JsonConverter<AgentModelConfig>
{
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( public sealed record AgentInfo(
string Id, string Id,
string Name, string Name,
@@ -94,7 +149,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
var agents = new List<AgentInfo>(configs.Count); var agents = new List<AgentInfo>(configs.Count);
foreach (var config in configs) foreach (var config in configs)
{ {
var model = config.Model ?? "deepseek/deepseek-v4-flash"; var model = ResolveModel(config);
var role = DeriveRole(config.Id); var role = DeriveRole(config.Id);
var description = config.Identity?.Theme ?? string.Empty; var description = config.Identity?.Theme ?? string.Empty;
@@ -141,7 +196,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
Id: config.Id, Id: config.Id,
Name: config.Identity?.Name ?? config.Name ?? config.Id, Name: config.Identity?.Name ?? config.Name ?? config.Id,
Role: role, Role: role,
Model: config.Model ?? "deepseek/deepseek-v4-flash", Model: ResolveModel(config),
Status: runtimeStatus.Status, Status: runtimeStatus.Status,
LastSeen: now, LastSeen: now,
Workspace: config.Workspace, Workspace: config.Workspace,
@@ -159,36 +214,43 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
return configs return configs
.Where(config => !string.IsNullOrWhiteSpace(config.Id)) .Where(config => !string.IsNullOrWhiteSpace(config.Id))
.Select(config => config.Id.Trim().ToLowerInvariant()) .Select(config => config.Id.Trim().ToLowerInvariant())
.DefaultIfEmpty()
.Where(id => !string.IsNullOrWhiteSpace(id))
.ToHashSet(StringComparer.OrdinalIgnoreCase); .ToHashSet(StringComparer.OrdinalIgnoreCase);
} }
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{ {
"iris" => "Orchestrator", "iris" => "Orchestrator",
"product-owner" => "Product Owner",
"programmer" => "Developer", "programmer" => "Developer",
"programmer-fast" => "Developer",
"reviewer" => "Reviewer", "reviewer" => "Reviewer",
"architekt" => "Architect", "architekt" => "Architect",
"main" => "Assistant", "main" => "Assistant",
_ => "Custom" _ => "Custom"
}; };
private static string ResolveModel(AgentConfig config)
=> config.Model?.Primary ?? "deepseek/deepseek-v4-flash";
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken) private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
{ {
var path = configuration.GetValue<string>("AgentConfigPath") var path = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json"; ?? "/home/node/.openclaw/openclaw.json";
if (!File.Exists(path)) if (!File.Exists(path))
return Array.Empty<AgentConfig>(); return BuildFallbackConfigs();
var json = await File.ReadAllTextAsync(path, cancellationToken); var json = await File.ReadAllTextAsync(path, cancellationToken);
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true }); using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true });
var root = document.RootElement; var root = document.RootElement;
if (!root.TryGetProperty("agents", out var agentsElement)) if (!root.TryGetProperty("agents", out var agentsElement))
return Array.Empty<AgentConfig>(); return BuildFallbackConfigs();
if (!agentsElement.TryGetProperty("list", out var listElement)) if (!agentsElement.TryGetProperty("list", out var listElement))
return Array.Empty<AgentConfig>(); return BuildFallbackConfigs();
var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement) var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement)
? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions) ? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions)
@@ -204,29 +266,35 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
// Inherit defaults for missing fields // Inherit defaults for missing fields
if (string.IsNullOrWhiteSpace(config.Name)) if (string.IsNullOrWhiteSpace(config.Name))
config = config with { Name = config.Id }; config = config with { Name = config.Id };
if (string.IsNullOrWhiteSpace(config.Model) && defaults?.Model?.Primary is not null) if (string.IsNullOrWhiteSpace(config.Model?.Primary) && defaults?.Model?.Primary is not null)
config = config with { Model = defaults.Model.Primary }; config = config with { Model = new AgentModelConfig { Primary = defaults.Model.Primary } };
if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null) if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null)
config = config with { Workspace = defaults.Workspace }; config = config with { Workspace = defaults.Workspace };
configs.Add(config); configs.Add(config);
} }
return configs.AsReadOnly(); return configs.Count > 0 ? configs.AsReadOnly() : BuildFallbackConfigs();
} }
private static IReadOnlyList<AgentConfig> 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 private sealed record AgentDefaults
{ {
[JsonPropertyName("workspace")] [JsonPropertyName("workspace")]
public string? Workspace { get; init; } public string? Workspace { get; init; }
[JsonPropertyName("model")] [JsonPropertyName("model")]
public AgentDefaultModel? Model { get; init; } [JsonConverter(typeof(AgentModelConfigConverter))]
} public AgentModelConfig? Model { get; init; }
private sealed record AgentDefaultModel
{
[JsonPropertyName("primary")]
public string? Primary { get; init; }
} }
} }
+1
View File
@@ -42,6 +42,7 @@ public interface ITaskBridgeService
string? priority = "Normal", string? priority = "Normal",
string? assignedTo = null, string? assignedTo = null,
string? expectedFrom = null, string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default); CancellationToken ct = default);
/// <summary> /// <summary>
+2 -1
View File
@@ -23,9 +23,10 @@ public interface ITaskService
// Dashboard-facing task operations // Dashboard-facing task operations
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default); Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default); Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default);
Task<WorkTask> CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default); Task<WorkTask> 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<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default); Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default);
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default); Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
Task<TaskOperationResult> StartCoordinationAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default); Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default); Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
@@ -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<string?> ResolveAllowedAgentHeaderAsync(
HttpContext httpContext,
IAgentService agentService,
CancellationToken ct)
=> (await ResolveAgentHeaderAsync(httpContext, agentService, ct)).AgentId;
public static async Task<AgentHeaderResolution> 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);
}
}
+34 -18
View File
@@ -15,6 +15,7 @@ namespace Nexus.Api.Services;
/// </summary> /// </summary>
public sealed class TaskBridgeService( public sealed class TaskBridgeService(
ITaskService taskService, ITaskService taskService,
IAgentService agentService,
IActivityRepository activityRepo, IActivityRepository activityRepo,
INotificationService notificationService, INotificationService notificationService,
ILiveUpdateService liveUpdateService) : ITaskBridgeService ILiveUpdateService liveUpdateService) : ITaskBridgeService
@@ -37,12 +38,11 @@ public sealed class TaskBridgeService(
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required."); return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
var normalizedSource = NormalizeSource(source); var normalizedSource = NormalizeSource(source);
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
var task = await taskService.CreateDashboardTaskAsync( 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); return Success(dto);
} }
@@ -56,6 +56,7 @@ public sealed class TaskBridgeService(
string? priority = "Normal", string? priority = "Normal",
string? assignedTo = null, string? assignedTo = null,
string? expectedFrom = null, string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (string.IsNullOrWhiteSpace(title)) if (string.IsNullOrWhiteSpace(title))
@@ -66,19 +67,23 @@ public sealed class TaskBridgeService(
if (parent is null) if (parent is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found."); return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found.");
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
var task = await taskService.CreateAgentTaskAsync( var task = await taskService.CreateAgentTaskAsync(
title.Trim(), detail?.Trim(), NormalizeSource(source), 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 parent was in Backlog, move it to InProgress (coordination starts)
if (string.Equals(parent.State, "Backlog", StringComparison.OrdinalIgnoreCase)) 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<DashboardTaskDto>(
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); return Success(dto);
} }
@@ -107,7 +112,7 @@ public sealed class TaskBridgeService(
if (result.Outcome != TaskOperationOutcome.Success) if (result.Outcome != TaskOperationOutcome.Success)
return Error<DashboardTaskDto>(TaskBridgeOutcome.InvalidState, "Status update rejected."); return Error<DashboardTaskDto>(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); return Success(dto);
} }
@@ -157,7 +162,10 @@ public sealed class TaskBridgeService(
if (task is null) if (task is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found."); return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
var normalizedTarget = targetAgent.Trim().ToLowerInvariant(); var normalizedTarget = await NormalizeActorAsync(targetAgent, ct);
if (normalizedTarget is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, $"Unknown target agent '{targetAgent}'.");
var handoffNote = string.IsNullOrWhiteSpace(note) var handoffNote = string.IsNullOrWhiteSpace(note)
? $"Handoff → {normalizedTarget}" ? $"Handoff → {normalizedTarget}"
: $"Handoff → {normalizedTarget}: {note.Trim()}"; : $"Handoff → {normalizedTarget}: {note.Trim()}";
@@ -186,7 +194,7 @@ public sealed class TaskBridgeService(
task.Id, task.Id,
ct); ct);
var dto = MapToDto(task); var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
return Success(dto); return Success(dto);
} }
@@ -207,8 +215,11 @@ public sealed class TaskBridgeService(
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync( public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
Guid parentTaskId, CancellationToken ct = default) Guid parentTaskId, CancellationToken ct = default)
{ {
var children = await taskService.GetChildTasksAsync(parentTaskId, ct); var board = await taskService.GetBoardAsync(ct);
return children.Select(MapToDto).ToList(); return FlattenBoard(board)
.Where(task => task.ParentTaskId == parentTaskId)
.OrderByDescending(task => task.UpdatedAt)
.ToList();
} }
public async Task<List<ActivityEvent>> GetTaskActivityAsync( public async Task<List<ActivityEvent>> GetTaskActivityAsync(
@@ -233,14 +244,19 @@ public sealed class TaskBridgeService(
private static string NormalizeSource(string? source) => private static string NormalizeSource(string? source) =>
string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant(); string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant();
private static string? NormalizeAssignedTo(string? assignedTo) private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(assignedTo)) return null; var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
var valid = new HashSet<string> { "bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor" }; return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
var lower = assignedTo.Trim().ToLowerInvariant();
return valid.Contains(lower) ? lower : null;
} }
private static IEnumerable<DashboardTaskDto> FlattenBoard(BoardResponse board)
=> board.Offen
.Concat(board.InProgress)
.Concat(board.Review)
.Concat(board.Blocked)
.Concat(board.Done);
private static DashboardTaskDto MapToDto(WorkTask t) => new( private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt, t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
+65 -32
View File
@@ -9,12 +9,10 @@ public sealed class TaskService(
ITaskRepository taskRepo, ITaskRepository taskRepo,
IActivityRepository activityRepo, IActivityRepository activityRepo,
INotificationService notificationService, INotificationService notificationService,
IAgentService agentService,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ILiveUpdateService liveUpdateService) : ITaskService ILiveUpdateService liveUpdateService) : ITaskService
{ {
private static readonly HashSet<string> ValidAssignees =
["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"];
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);
@@ -90,12 +88,7 @@ public sealed class TaskService(
if (!TaskStateHelper.CanChangeState(caller, task)) if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState); return new TaskOperationResult(TaskOperationOutcome.InvalidState);
task.State = canonical; return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task {task.Title} moved to {canonical}", ct);
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);
} }
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default) public async Task<TaskOperationResult> 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 normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
var normalizedAssignee = ValidateAssignedTo(assignedTo); var normalizedAssignee = await NormalizeActorAsync(assignedTo, ct);
var isVisibleDelegation = parentTaskId.HasValue; var isVisibleDelegation = parentTaskId.HasValue;
var task = new WorkTask var task = new WorkTask
@@ -250,14 +243,14 @@ public sealed class TaskService(
public async Task<WorkTask> CreateAgentTaskAsync( public async Task<WorkTask> CreateAgentTaskAsync(
string title, string? detail, string? source, string? priority, 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); var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
task.IsAgentTask = true; task.IsAgentTask = true;
task.ExpectedFrom = normalizedExpectedFrom; task.ExpectedFrom = normalizedExpectedFrom;
task.State = TaskStateHelper.ToStateString(TaskState.InProgress); task.State = ResolveInitialAgentTaskState(startsInProgress, initialState);
await taskRepo.UpdateAsync(task, ct); await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent await activityRepo.AddAsync(new ActivityEvent
@@ -322,7 +315,7 @@ public sealed class TaskService(
} }
if (assignedTo is not null) if (assignedTo is not null)
{ {
var validated = ValidateAssignedTo(assignedTo); var validated = await NormalizeActorAsync(assignedTo, ct);
if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase))
{ {
changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}"); changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}");
@@ -373,12 +366,24 @@ public sealed class TaskService(
return new TaskOperationResult(TaskOperationOutcome.InvalidState); return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase)); var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
task.State = canonical; return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", null, ct);
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); public async Task<TaskOperationResult> StartCoordinationAsync(Guid id, CancellationToken ct = default)
await PublishBoardSnapshotAsync(ct); {
return new TaskOperationResult(TaskOperationOutcome.Success, task); 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<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default) public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
@@ -481,12 +486,7 @@ public sealed class TaskService(
if (!TaskStateHelper.CanChangeState(caller, task)) if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState); return new TaskOperationResult(TaskOperationOutcome.InvalidState);
task.State = canonical; return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
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);
} }
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default) public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
@@ -577,11 +577,25 @@ public sealed class TaskService(
t.ParentTaskId.HasValue || t.IsAgentTask); t.ParentTaskId.HasValue || t.IsAgentTask);
} }
private static string? ValidateAssignedTo(string? assignedTo) private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(assignedTo)) return null; var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
var lower = assignedTo.Trim().ToLowerInvariant(); return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
return ValidAssignees.Contains(lower) ? lower : null; }
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() private string ResolveCaller()
@@ -598,10 +612,29 @@ public sealed class TaskService(
return nameClaim?.ToLowerInvariant() ?? ""; return nameClaim?.ToLowerInvariant() ?? "";
} }
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct) private async Task<TaskOperationResult> 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)) if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase))
{ {
await notificationService.CreateAsync( await notificationService.CreateAsync(
+5 -2
View File
@@ -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` **„Nexus Taskflow auf Parent-/Child-Modell umstellen“** — Owner: `iris`
### Mögliche Child-Tasks ### Mögliche Child-Tasks
- **Backend-State-Handling anpassen** — Owner: `developer` - **PO-Spezifikation und Akzeptanzkriterien ausarbeiten** — Owner: `product-owner`
- **Frontend-Board-Spalten und Labels anpassen** — Owner: `developer` - **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` - **Workflow verifizieren / Regression prüfen** — Owner: `reviewer`
- **Deploy-/Runtime-Auswirkung prüfen** — Owner: `architekt` - **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 - `parentTaskId` verknüpft Child-Tasks mit der Parent-Task
- `AssignedTo` zeigt den operativen Owner - `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 - 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
+19
View File
@@ -1,5 +1,24 @@
import type { AgentNodeData } from '../types/agentNode' 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<string, string> = Object.fromEntries(
TASK_AGENT_OPTIONS
.filter(option => option.id)
.map(option => [option.id, option.label])
) as Record<string, string>
export const EXTRA_AGENT_POOL: AgentNodeData[] = [ export const EXTRA_AGENT_POOL: AgentNodeData[] = [
{ {
id: 'qa', id: 'qa',
+4
View File
@@ -17,7 +17,9 @@ interface CatalogEntry {
const AGENT_CATALOG: Record<string, CatalogEntry> = { const AGENT_CATALOG: Record<string, CatalogEntry> = {
iris: { elapsed: '--', think: null, next: 'Standby' }, iris: { elapsed: '--', think: null, next: 'Standby' },
'product-owner': { elapsed: '--', think: null, next: 'Standby' },
programmer: { elapsed: '--', think: null, next: 'Standby' }, programmer: { elapsed: '--', think: null, next: 'Standby' },
'programmer-fast': { elapsed: '--', think: null, next: 'Standby' },
developer: { elapsed: '--', think: null, next: 'Standby' }, developer: { elapsed: '--', think: null, next: 'Standby' },
architekt: { elapsed: '--', think: null, next: 'Standby' }, architekt: { elapsed: '--', think: null, next: 'Standby' },
reviewer: { 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 { function resolveAvatar(id: string, name: string): string {
if (id === 'iris') return 'IR' if (id === 'iris') return 'IR'
if (id === 'product-owner') return 'PO'
if (id === 'programmer' || id === 'developer') return '</>' if (id === 'programmer' || id === 'developer') return '</>'
if (id === 'programmer-fast') return 'PF'
return name.slice(0, 2).toUpperCase() return name.slice(0, 2).toUpperCase()
} }
+8
View File
@@ -29,6 +29,10 @@ export interface DashboardTaskDto {
expectedFrom?: string | null expectedFrom?: string | null
lastActivityMessage?: string | null lastActivityMessage?: string | null
lastActivityAt?: string | null lastActivityAt?: string | null
childTasks?: DashboardTaskDto[] | null
childTaskCount?: number
openChildTaskCount?: number
hasVisibleDelegation?: boolean
} }
export interface BoardGroup { export interface BoardGroup {
@@ -319,6 +323,8 @@ export const useTaskStore = defineStore('tasks', {
assignedTo?: string assignedTo?: string
expectedFrom?: string expectedFrom?: string
parentTaskId?: string | null parentTaskId?: string | null
startsInProgress?: boolean
initialState?: string | null
}) { }) {
try { try {
const res = await apiFetch('/api/dashboard/tasks/agent', { const res = await apiFetch('/api/dashboard/tasks/agent', {
@@ -331,6 +337,8 @@ export const useTaskStore = defineStore('tasks', {
assignedTo: data.assignedTo ?? null, assignedTo: data.assignedTo ?? null,
expectedFrom: data.expectedFrom ?? null, expectedFrom: data.expectedFrom ?? null,
parentTaskId: data.parentTaskId ?? null, parentTaskId: data.parentTaskId ?? null,
startsInProgress: data.startsInProgress ?? true,
initialState: data.initialState ?? null,
}), }),
}) })
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
+13 -26
View File
@@ -17,7 +17,8 @@ import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, A
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useTaskStore } from '../stores/tasks' 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<typeof flattenBoard>[number] type BoardTask = ReturnType<typeof flattenBoard>[number]
@@ -222,16 +223,7 @@ const liveModeClass = computed(() => `live-pill-${liveSyncStore.connectionHealth
function expectedFromLabel(expected: string | null | undefined): string { function expectedFromLabel(expected: string | null | undefined): string {
if (!expected) return '' if (!expected) return ''
const map: Record<string, string> = { return TASK_AGENT_LABELS[expected.toLowerCase()] ?? expected
'bao': '👤 Bao',
'iris': '🤖 Iris',
'programmer': '🛠 Programmer',
'reviewer': '🔎 Reviewer',
'architekt': '🏛 Architekt',
'researcher': '🔬 Researcher',
'executor': '⚡ Executor',
}
return map[expected.toLowerCase()] ?? expected
} }
function hoursSince(dateStr: string): number { function hoursSince(dateStr: string): number {
@@ -827,13 +819,13 @@ onUnmounted(() => {
<div class="field"> <div class="field">
<label for="task-assignee">Zugewiesen an</label> <label for="task-assignee">Zugewiesen an</label>
<select id="task-assignee" v-model="formAssignedTo" class="field-input field-select"> <select id="task-assignee" v-model="formAssignedTo" class="field-input field-select">
<option value="bao">👤 Bao</option> <option
<option value="iris">🤖 Iris</option> v-for="option in TASK_AGENT_OPTIONS.filter(entry => entry.id)"
<option value="programmer">🛠 Programmer</option> :key="option.id"
<option value="reviewer">🔎 Reviewer</option> :value="option.id"
<option value="architekt">🏛 Architekt</option> >
<option value="researcher">🔬 Researcher</option> {{ option.label }}
<option value="executor"> Executor</option> </option>
</select> </select>
</div> </div>
</div> </div>
@@ -951,14 +943,9 @@ onUnmounted(() => {
<label class="sidebar-field"> <label class="sidebar-field">
<span>Zuständig</span> <span>Zuständig</span>
<select v-model="detailForm.assignedTo" class="field-input field-select slim"> <select v-model="detailForm.assignedTo" class="field-input field-select slim">
<option value="">Nicht zugewiesen</option> <option v-for="option in TASK_AGENT_OPTIONS" :key="option.id || 'unassigned'" :value="option.id">
<option value="bao">👤 Bao</option> {{ option.label }}
<option value="iris">🤖 Iris</option> </option>
<option value="programmer">🛠 Programmer</option>
<option value="reviewer">🔎 Reviewer</option>
<option value="architekt">🏛 Architekt</option>
<option value="researcher">🔬 Researcher</option>
<option value="executor"> Executor</option>
</select> </select>
</label> </label>
<label class="sidebar-field"> <label class="sidebar-field">
+13 -10
View File
@@ -16,6 +16,7 @@ import {
} from '@lucide/vue' } from '@lucide/vue'
import { apiFetch } from '../services/api' import { apiFetch } from '../services/api'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
/* ── Types ──────────────────────────────────── */ /* ── Types ──────────────────────────────────── */
interface TaskDto { interface TaskDto {
@@ -166,7 +167,9 @@ function childStatusSummary(taskId: string): string {
} }
function progressHint(taskLike: Pick<TaskDto, 'id' | 'lastActivityMessage' | 'expectedFrom'>): string { function progressHint(taskLike: Pick<TaskDto, 'id' | 'lastActivityMessage' | 'expectedFrom'>): string {
return taskLike.lastActivityMessage?.trim() || childStatusSummary(taskLike.id) || (taskLike.expectedFrom ? `Wartet auf ${taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status') return taskLike.lastActivityMessage?.trim()
|| childStatusSummary(taskLike.id)
|| (taskLike.expectedFrom ? `Wartet auf ${TASK_AGENT_LABELS[taskLike.expectedFrom.toLowerCase()] ?? taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status')
} }
function delegationSummary(taskLike: TaskDto): string | null { function delegationSummary(taskLike: TaskDto): string | null {
@@ -451,7 +454,11 @@ function handleKeydown(e: KeyboardEvent) {
<option value="Medium">Medium</option> <option value="Medium">Medium</option>
<option value="Low">Low</option> <option value="Low">Low</option>
</select> </select>
<input v-model="subtaskAssign" class="galaxy-input narrow" placeholder="Zuständig (bao, iris, researcher…)" /> <select v-model="subtaskAssign" class="galaxy-input galaxy-select narrow">
<option v-for="option in TASK_AGENT_OPTIONS" :key="option.id || 'unassigned'" :value="option.id">
{{ option.label }}
</option>
</select>
<button class="btn-primary btn-sm" @click="createSubtask" :disabled="creatingSubtask"> <button class="btn-primary btn-sm" @click="createSubtask" :disabled="creatingSubtask">
{{ creatingSubtask ? 'Erstelle…' : 'Anlegen' }} {{ creatingSubtask ? 'Erstelle…' : 'Anlegen' }}
</button> </button>
@@ -565,14 +572,9 @@ function handleKeydown(e: KeyboardEvent) {
<label class="sidebar-field"> <label class="sidebar-field">
<span>Zuständig</span> <span>Zuständig</span>
<select v-model="form.assignedTo" class="galaxy-input galaxy-select"> <select v-model="form.assignedTo" class="galaxy-input galaxy-select">
<option value="">Nicht zugewiesen</option> <option v-for="option in TASK_AGENT_OPTIONS" :key="option.id || 'unassigned'" :value="option.id">
<option value="bao">👤 Bao</option> {{ option.label }}
<option value="iris">🤖 Iris</option> </option>
<option value="programmer">🛠 Programmer</option>
<option value="reviewer">🔎 Reviewer</option>
<option value="architekt">🏛 Architekt</option>
<option value="researcher">🔬 Researcher</option>
<option value="executor"> Executor</option>
</select> </select>
</label> </label>
<label class="sidebar-field"> <label class="sidebar-field">
@@ -590,6 +592,7 @@ function handleKeydown(e: KeyboardEvent) {
<div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</dd></div> <div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</dd></div>
<div><dt>Geändert</dt><dd>{{ formatDate(task.updatedAt, true) }}</dd></div> <div><dt>Geändert</dt><dd>{{ formatDate(task.updatedAt, true) }}</dd></div>
<div v-if="task.isAgentTask"><dt>Letzter Status</dt><dd>{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</dd></div> <div v-if="task.isAgentTask"><dt>Letzter Status</dt><dd>{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</dd></div>
<div v-if="task.expectedFrom"><dt>Erwartet von</dt><dd>{{ TASK_AGENT_LABELS[task.expectedFrom.toLowerCase()] ?? task.expectedFrom }}</dd></div>
<div v-if="task.parentTaskId"><dt>Task-Typ</dt><dd>Sichtbare Child-Task</dd></div> <div v-if="task.parentTaskId"><dt>Task-Typ</dt><dd>Sichtbare Child-Task</dd></div>
</dl> </dl>
</section> </section>