feat: board-first orchestration with Gateway Bridge, live-update, and flow-board
- GatewayBridgeController: MCP-artiger Kommando-Adapter für Agent-zu-Backend - TaskBridgeService + LiveUpdateService: SSE Live-Sync + Bridge-Kommandos - FlowBoard.vue: Board-first orchestration dashboard panel - live-sync.ts store + live.ts service: SSE-basierte Live-Updates - Nullability-Warnung in HealthController.cs gefixt - nginx.conf: SSE-Proxy + CORS für Bridge-Endpunkte - .gitignore: pnpm/corepack local caches ausgeschlossen - docs: architecture-board-first-orchestration.md hinzugefügt - README: Backend Bridge API dokumentiert
This commit is contained in:
@@ -15,7 +15,9 @@ public class DashboardController(
|
||||
IDashboardService dashboardService,
|
||||
ITaskService taskService,
|
||||
IActivityRepository activityService,
|
||||
IHttpContextAccessor httpContextAccessor) : ControllerBase
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
INotificationService notificationService,
|
||||
ILiveUpdateService liveUpdateService) : ControllerBase
|
||||
{
|
||||
[HttpGet("status")]
|
||||
public async Task<DashboardStatus> GetStatus()
|
||||
@@ -193,6 +195,69 @@ public class DashboardController(
|
||||
public async Task<BoardResponse> GetBoard(CancellationToken ct)
|
||||
=> await taskService.GetBoardAsync(ct);
|
||||
|
||||
[HttpGet("live")]
|
||||
public async Task Live(
|
||||
[FromQuery] string forUser = "bao",
|
||||
[FromQuery] int notificationLimit = 50,
|
||||
[FromQuery] long? afterSequence = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Response.Headers.Append("Content-Type", "text/event-stream");
|
||||
Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
Response.Headers.Append("Connection", "keep-alive");
|
||||
Response.Headers.Append("X-Accel-Buffering", "no");
|
||||
|
||||
async Task WriteEventAsync(string eventName, object payload)
|
||||
{
|
||||
await Response.WriteAsync($"event: {eventName}\n", ct);
|
||||
await Response.WriteAsync($"data: {System.Text.Json.JsonSerializer.Serialize(payload)}\n\n", ct);
|
||||
await Response.Body.FlushAsync(ct);
|
||||
}
|
||||
|
||||
var currentSequence = liveUpdateService.CurrentSequence;
|
||||
var initial = new DashboardLiveSnapshotDto(
|
||||
await taskService.GetBoardAsync(ct),
|
||||
await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct),
|
||||
new LiveCursorDto(currentSequence, DateTimeOffset.UtcNow, "live"));
|
||||
await WriteEventAsync("snapshot", initial);
|
||||
|
||||
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
|
||||
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||
var completed = await Task.WhenAny(readTask, heartbeatTask);
|
||||
|
||||
if (completed == readTask)
|
||||
{
|
||||
var envelope = await readTask;
|
||||
if (envelope.Type == "notifications.snapshot")
|
||||
{
|
||||
var snapshot = envelope.Payload as NotificationSnapshotDto
|
||||
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
|
||||
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
envelope = envelope with { Payload = snapshot };
|
||||
}
|
||||
|
||||
if (envelope.Type == "tasks.board.snapshot")
|
||||
{
|
||||
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
|
||||
}
|
||||
|
||||
await WriteEventAsync("update", new DashboardLiveEventDto(
|
||||
envelope,
|
||||
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
|
||||
}
|
||||
else if (await heartbeatTask)
|
||||
{
|
||||
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("tasks/{id:guid}/move")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> MoveTask(
|
||||
Guid id, [FromBody] MoveTaskRequest request, CancellationToken ct)
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// MCP-style (structured-command) backend bridge for agent-facing operations.
|
||||
///
|
||||
/// This is the SINGLE entrypoint for agents (Iris + sub-agents) to interact with
|
||||
/// the Nexus task board, activity log, and delegation workflow.
|
||||
///
|
||||
/// AUTHENTICATION: Requires X-Nexus-Api-Key or a known allowed X-Agent-Id.
|
||||
/// The browser NEVER uses this controller — only backend-to-backend and gateway-to-backend.
|
||||
///
|
||||
/// DESIGN PRINCIPLE: No MCP protocol between Nexus and Gateway — instead, the Gateway
|
||||
/// calls these structured HTTP endpoints (same pattern, simpler transport).
|
||||
///
|
||||
/// COMMANDS:
|
||||
/// create_task → POST /api/bridge/tasks
|
||||
/// create_child_task → POST /api/bridge/tasks/{id}/children
|
||||
/// update_status → PATCH /api/bridge/tasks/{id}/status
|
||||
/// append_activity → POST /api/bridge/tasks/{id}/activity
|
||||
/// handoff → POST /api/bridge/tasks/{id}/handoff
|
||||
/// get_board → GET /api/bridge/board
|
||||
/// get_task → GET /api/bridge/tasks/{id}
|
||||
/// get_children → GET /api/bridge/tasks/{id}/children
|
||||
/// get_activity → GET /api/bridge/tasks/{id}/activity
|
||||
/// get_agent_overview → GET /api/bridge/agent-overview
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/bridge")]
|
||||
[EnableRateLimiting("agents")]
|
||||
public class GatewayBridgeController(
|
||||
ITaskBridgeService bridge,
|
||||
IAgentService agentService,
|
||||
ILogger<GatewayBridgeController> logger) : ControllerBase
|
||||
{
|
||||
private const string ApikeyErrorMessage =
|
||||
"Bridge endpoints require X-Nexus-Api-Key or X-Agent-Id header with a recognized agent identity.";
|
||||
|
||||
[HttpGet("health")]
|
||||
public IResult Health()
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
status = "ok",
|
||||
service = "nexus-bridge",
|
||||
version = "1.0.0",
|
||||
commands = new[]
|
||||
{
|
||||
"create_task", "create_child_task", "update_status",
|
||||
"append_activity", "handoff", "get_board", "get_task",
|
||||
"get_children", "get_activity", "get_agent_overview"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("tasks")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> CreateTask(
|
||||
[FromBody] BridgeCreateTaskCommand command,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var agentId = resolution.AgentId;
|
||||
var result = await bridge.CreateTaskAsync(
|
||||
title: command.Title,
|
||||
detail: command.Detail,
|
||||
source: ResolveSource(agentId),
|
||||
priority: command.Priority ?? "Normal",
|
||||
assignedTo: command.AssignedTo ?? agentId,
|
||||
projectId: command.ProjectId,
|
||||
ct: ct);
|
||||
|
||||
return MapResult(result, "create_task");
|
||||
}
|
||||
|
||||
[HttpPost("tasks/{parentTaskId:guid}/children")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> CreateChildTask(
|
||||
Guid parentTaskId,
|
||||
[FromBody] BridgeCreateChildTaskCommand command,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var agentId = resolution.AgentId;
|
||||
var result = await bridge.CreateChildTaskAsync(
|
||||
parentTaskId: parentTaskId,
|
||||
title: command.Title,
|
||||
detail: command.Detail,
|
||||
source: ResolveSource(agentId),
|
||||
priority: command.Priority ?? "Normal",
|
||||
assignedTo: command.AssignedTo,
|
||||
expectedFrom: command.ExpectedFrom ?? command.AssignedTo,
|
||||
ct: ct);
|
||||
|
||||
return MapResult(result, "create_child_task");
|
||||
}
|
||||
|
||||
[HttpPatch("tasks/{taskId:guid}/status")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> UpdateStatus(
|
||||
Guid taskId,
|
||||
[FromBody] BridgeUpdateStatusCommand command,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var agentId = resolution.AgentId;
|
||||
var result = await bridge.UpdateStatusAsync(
|
||||
taskId: taskId,
|
||||
state: command.State,
|
||||
callerAgent: agentId,
|
||||
ct: ct);
|
||||
|
||||
return MapResult(result, "update_status");
|
||||
}
|
||||
|
||||
[HttpPost("tasks/{taskId:guid}/activity")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<ActivityEntryDto>>> AppendActivity(
|
||||
Guid taskId,
|
||||
[FromBody] BridgeAppendActivityCommand command,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var result = await bridge.AppendActivityAsync(
|
||||
taskId: taskId,
|
||||
message: command.Message,
|
||||
type: command.Type ?? "comment",
|
||||
ct: ct);
|
||||
|
||||
return MapActivityResult(result, "append_activity");
|
||||
}
|
||||
|
||||
[HttpPost("tasks/{taskId:guid}/handoff")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> Handoff(
|
||||
Guid taskId,
|
||||
[FromBody] BridgeHandoffCommand command,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var result = await bridge.HandoffAsync(
|
||||
taskId: taskId,
|
||||
targetAgent: command.TargetAgent,
|
||||
note: command.Note,
|
||||
ct: ct);
|
||||
|
||||
return MapResult(result, "handoff");
|
||||
}
|
||||
|
||||
[HttpGet("board")]
|
||||
public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
return Ok(await bridge.GetBoardAsync(ct));
|
||||
}
|
||||
|
||||
[HttpGet("tasks/{taskId:guid}")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> GetTask(
|
||||
Guid taskId, CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var result = await bridge.GetTaskAsync(taskId, ct);
|
||||
return MapResult(result, "get_task");
|
||||
}
|
||||
|
||||
[HttpGet("tasks/{taskId:guid}/children")]
|
||||
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(
|
||||
Guid taskId, CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
return Ok(await bridge.GetChildTasksAsync(taskId, ct));
|
||||
}
|
||||
|
||||
[HttpGet("tasks/{taskId:guid}/activity")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<List<ActivityEntryDto>>>> GetActivity(
|
||||
Guid taskId, CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var events = await bridge.GetTaskActivityAsync(taskId, ct);
|
||||
var entries = events.Select(e => new ActivityEntryDto(e.Id, e.Type, e.Message, e.CreatedAt)).ToList();
|
||||
|
||||
return Ok(new TaskBridgeCommandResponse<List<ActivityEntryDto>>
|
||||
{
|
||||
Ok = true,
|
||||
Command = "get_activity",
|
||||
Data = entries
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("agent-overview")]
|
||||
public async Task<ActionResult<AgentWorkflowOverview>> GetAgentOverview(
|
||||
CancellationToken ct,
|
||||
[FromQuery] int staleHours = 2)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var threshold = TimeSpan.FromHours(Math.Max(1, staleHours));
|
||||
return Ok(await bridge.GetAgentOverviewAsync(threshold, ct));
|
||||
}
|
||||
|
||||
private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct)
|
||||
{
|
||||
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
|
||||
|
||||
var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||
{
|
||||
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
|
||||
if (allowedAgentIds.Contains(normalizedHeader))
|
||||
return (true, normalizedHeader, null);
|
||||
|
||||
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback",
|
||||
normalizedHeader,
|
||||
HttpContext.Connection.RemoteIpAddress);
|
||||
}
|
||||
|
||||
if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedAgentIds.Contains(normalizedClaim))
|
||||
return (true, normalizedClaim, null);
|
||||
|
||||
if (User.IsInRole("owner") || User.IsInRole("admin") || User.IsInRole("member"))
|
||||
return (true, "bao", null);
|
||||
}
|
||||
|
||||
if (User.IsInRole("Service") && allowedAgentIds.Contains("nexus-system"))
|
||||
return (true, "nexus-system", null);
|
||||
|
||||
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
|
||||
logger.LogWarning("Bridge: unauthenticated request rejected from {Ip}", HttpContext.Connection.RemoteIpAddress);
|
||||
return (false, string.Empty, unauthorized);
|
||||
}
|
||||
|
||||
private static string ResolveSource(string agentId) => agentId switch
|
||||
{
|
||||
"bao" or "nexus-system" => "bao",
|
||||
_ => agentId
|
||||
};
|
||||
|
||||
private static ActionResult MapResult<T>(TaskBridgeResult<T> result, string command) where T : class
|
||||
{
|
||||
if (result.Outcome == TaskBridgeOutcome.Success)
|
||||
return new OkObjectResult(new TaskBridgeCommandResponse<T>
|
||||
{
|
||||
Ok = true,
|
||||
Command = command,
|
||||
Data = result.Data
|
||||
});
|
||||
|
||||
var statusCode = result.Outcome switch
|
||||
{
|
||||
TaskBridgeOutcome.NotFound => 404,
|
||||
TaskBridgeOutcome.InvalidState => 422,
|
||||
TaskBridgeOutcome.Unauthorized => 403,
|
||||
TaskBridgeOutcome.ValidationError => 400,
|
||||
_ => 500
|
||||
};
|
||||
|
||||
return new ObjectResult(new TaskBridgeCommandResponse<T>
|
||||
{
|
||||
Ok = false,
|
||||
Command = command,
|
||||
Error = result.Error ?? "Unknown error"
|
||||
}) { StatusCode = statusCode };
|
||||
}
|
||||
|
||||
private static ActionResult MapActivityResult(TaskBridgeResult<Data.ActivityEvent> result, string command)
|
||||
{
|
||||
if (result.Outcome == TaskBridgeOutcome.Success)
|
||||
return new OkObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
|
||||
{
|
||||
Ok = true,
|
||||
Command = command,
|
||||
Data = result.Data is null ? null : new ActivityEntryDto(
|
||||
result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt)
|
||||
});
|
||||
|
||||
var statusCode = result.Outcome switch
|
||||
{
|
||||
TaskBridgeOutcome.NotFound => 404,
|
||||
TaskBridgeOutcome.ValidationError => 400,
|
||||
_ => 500
|
||||
};
|
||||
|
||||
return new ObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
|
||||
{
|
||||
Ok = false,
|
||||
Command = command,
|
||||
Error = result.Error ?? "Unknown error"
|
||||
}) { StatusCode = statusCode };
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TaskBridgeCommandResponse<T>
|
||||
{
|
||||
public bool Ok { get; init; }
|
||||
public string Command { get; init; } = string.Empty;
|
||||
public T? Data { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public string Timestamp { get; init; } = DateTimeOffset.UtcNow.ToString("o");
|
||||
}
|
||||
|
||||
public sealed record BridgeCreateTaskCommand(
|
||||
string Title,
|
||||
string? Detail = null,
|
||||
string? Priority = null,
|
||||
string? AssignedTo = null,
|
||||
Guid? ProjectId = null
|
||||
);
|
||||
|
||||
public sealed record BridgeCreateChildTaskCommand(
|
||||
string Title,
|
||||
string? Detail = null,
|
||||
string? Priority = null,
|
||||
string? AssignedTo = null,
|
||||
string? ExpectedFrom = null
|
||||
);
|
||||
|
||||
public sealed record BridgeUpdateStatusCommand(string State);
|
||||
|
||||
public sealed record BridgeAppendActivityCommand(
|
||||
string Message,
|
||||
string? Type = null
|
||||
);
|
||||
|
||||
public sealed record BridgeHandoffCommand(
|
||||
string TargetAgent,
|
||||
string? Note = null
|
||||
);
|
||||
|
||||
public sealed record ActivityEntryDto(
|
||||
long Id,
|
||||
string Type,
|
||||
string Message,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
@@ -40,14 +40,14 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
|
||||
{
|
||||
status = e.Value.Status.ToString(),
|
||||
description = e.Value.Description,
|
||||
data = e.Value.Data
|
||||
data = (IReadOnlyDictionary<string, object?>)e.Value.Data
|
||||
});
|
||||
|
||||
entries["runtime"] = new
|
||||
{
|
||||
status = runtimeStatus,
|
||||
description = runtimeDetail ?? "Runtime status checked",
|
||||
data = (IReadOnlyDictionary<string, object>)new Dictionary<string, object>()
|
||||
description = runtimeDetail,
|
||||
data = (IReadOnlyDictionary<string, object?>)new Dictionary<string, object?>()
|
||||
};
|
||||
|
||||
var isHealthy = report.Status == HealthStatus.Healthy && runtimeStatus == "Online";
|
||||
|
||||
@@ -31,6 +31,16 @@ public class NotificationsController(INotificationService notificationService) :
|
||||
return Ok(new UnreadCountDto(count));
|
||||
}
|
||||
|
||||
[HttpGet("snapshot")]
|
||||
public async Task<ActionResult<NotificationSnapshotDto>> GetSnapshot(
|
||||
[FromQuery] string forUser = "bao",
|
||||
[FromQuery] int limit = 50,
|
||||
[FromQuery] bool unreadOnly = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Ok(await notificationService.GetSnapshotAsync(forUser, limit, unreadOnly, ct));
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/read")]
|
||||
public async Task<ActionResult> MarkAsRead(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/projects")]
|
||||
public class ProjectsController(IProjectService projectService) : ControllerBase
|
||||
|
||||
@@ -7,9 +7,10 @@ using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/tasks")]
|
||||
public class TasksController(ITaskService taskService) : ControllerBase
|
||||
public class TasksController(ITaskService taskService, IAgentService agentService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetAll(CancellationToken ct)
|
||||
@@ -111,21 +112,57 @@ public class TasksController(ITaskService taskService) : ControllerBase
|
||||
/// <summary>
|
||||
/// Gibt das Task-Board zurück (gruppiert nach Status, priorisiert sortiert).
|
||||
/// Wird vom Iris Autonomous Worker genutzt.
|
||||
///
|
||||
/// SICHERHEIT: Erfordert X-Agent-Id Header (bel. erkannter Agent) ODER
|
||||
/// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr.
|
||||
/// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("board")]
|
||||
public async Task<IResult> GetBoard(CancellationToken ct)
|
||||
=> Results.Ok(await taskService.GetBoardAsync(ct));
|
||||
{
|
||||
// Erfordert mindestens einen identifizierbaren Agent-Aufrufer
|
||||
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
|
||||
var isApiKey = HttpContext.User.IsInRole("Service");
|
||||
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth)
|
||||
return Results.Unauthorized();
|
||||
|
||||
return Results.Ok(await taskService.GetBoardAsync(ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setzt stale Tasks (InProgress, älter als N Stunden) zurück auf Backlog.
|
||||
/// Wird vom Iris Autonomous Worker genutzt.
|
||||
///
|
||||
/// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER
|
||||
/// X-Nexus-Api-Key / JWT-authenticated user.
|
||||
/// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("reset-stale")]
|
||||
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
|
||||
{
|
||||
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
|
||||
var isApiKey = HttpContext.User.IsInRole("Service");
|
||||
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
|
||||
|
||||
// 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,9 +216,13 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<IMemoryService, MemoryService>();
|
||||
services.AddSingleton<IIncidentService, IncidentService>();
|
||||
services.AddSingleton<IDocService, DocService>();
|
||||
services.AddSingleton<ILiveUpdateService, LiveUpdateService>();
|
||||
services.AddScoped<INotificationService, NotificationService>();
|
||||
services.AddScoped<ICalendarService, CalendarService>();
|
||||
|
||||
// ── Backend Bridge (Agent-Command-Service) ──
|
||||
services.AddScoped<ITaskBridgeService, TaskBridgeService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,11 @@ public sealed record DashboardTaskDto(
|
||||
bool IsAgentTask = false,
|
||||
string? ExpectedFrom = null,
|
||||
string? LastActivityMessage = null,
|
||||
DateTimeOffset? LastActivityAt = null
|
||||
DateTimeOffset? LastActivityAt = null,
|
||||
List<DashboardTaskDto>? ChildTasks = null,
|
||||
int ChildTaskCount = 0,
|
||||
int OpenChildTaskCount = 0,
|
||||
bool HasVisibleDelegation = false
|
||||
);
|
||||
|
||||
public sealed record CreateDashboardTaskRequest(
|
||||
@@ -182,3 +186,34 @@ public sealed record NotificationDto(
|
||||
);
|
||||
|
||||
public sealed record UnreadCountDto(int Count);
|
||||
|
||||
public sealed record LiveUpdateEnvelope(
|
||||
string Type,
|
||||
DateTimeOffset Timestamp,
|
||||
object Payload,
|
||||
long Sequence,
|
||||
string Channel
|
||||
);
|
||||
|
||||
public sealed record LiveCursorDto(
|
||||
long Sequence,
|
||||
DateTimeOffset Timestamp,
|
||||
string Mode
|
||||
);
|
||||
|
||||
public sealed record NotificationSnapshotDto(
|
||||
List<NotificationDto> Notifications,
|
||||
int UnreadCount,
|
||||
string ForUser
|
||||
);
|
||||
|
||||
public sealed record DashboardLiveSnapshotDto(
|
||||
BoardResponse Board,
|
||||
NotificationSnapshotDto Notifications,
|
||||
LiveCursorDto Cursor
|
||||
);
|
||||
|
||||
public sealed record DashboardLiveEventDto(
|
||||
LiveUpdateEnvelope Envelope,
|
||||
LiveCursorDto Cursor
|
||||
);
|
||||
|
||||
@@ -73,6 +73,7 @@ public interface IAgentService
|
||||
{
|
||||
Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken);
|
||||
Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken);
|
||||
Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class AgentService(IConfiguration configuration, IAgentRuntime runtime) : IAgentService
|
||||
@@ -151,6 +152,16 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public async Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var configs = await LoadAgentConfigsAsync(cancellationToken);
|
||||
return configs
|
||||
.Where(config => !string.IsNullOrWhiteSpace(config.Id))
|
||||
.Select(config => config.Id.Trim().ToLowerInvariant())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
||||
{
|
||||
"iris" => "Orchestrator",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Threading.Channels;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public interface ILiveUpdateService
|
||||
{
|
||||
Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default);
|
||||
LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard");
|
||||
long CurrentSequence { get; }
|
||||
}
|
||||
|
||||
public sealed class LiveUpdateSubscription
|
||||
{
|
||||
public ChannelReader<LiveUpdateEnvelope> Reader { get; init; } = default!;
|
||||
public long StartingSequence { get; init; }
|
||||
}
|
||||
@@ -10,4 +10,5 @@ public interface INotificationService
|
||||
Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default);
|
||||
Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default);
|
||||
Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default);
|
||||
Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Structured backend bridge for agent/task commands.
|
||||
/// Provides a clean, typed API for agents (Iris and sub-agents) to interact
|
||||
/// with the task board, activity log, and delegation workflow.
|
||||
///
|
||||
/// This is the internal service layer — never exposed directly to the browser.
|
||||
/// The GatewayBridgeController wraps this for agent-facing HTTP access.
|
||||
/// </summary>
|
||||
public interface ITaskBridgeService
|
||||
{
|
||||
// ── Task CRUD (Agent-Commands) ──
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new top-level task (parent or standalone).
|
||||
/// Returns the created task DTO.
|
||||
/// </summary>
|
||||
Task<TaskBridgeResult<DashboardTaskDto>> CreateTaskAsync(
|
||||
string title,
|
||||
string? detail = null,
|
||||
string? source = "iris",
|
||||
string? priority = "Normal",
|
||||
string? assignedTo = null,
|
||||
Guid? projectId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a child task linked to an existing parent.
|
||||
/// This is the primary delegation command: iris creates a child task,
|
||||
/// assigns it to a sub-agent, and tracks it on the board.
|
||||
/// </summary>
|
||||
Task<TaskBridgeResult<DashboardTaskDto>> CreateChildTaskAsync(
|
||||
Guid parentTaskId,
|
||||
string title,
|
||||
string? detail = null,
|
||||
string? source = "iris",
|
||||
string? priority = "Normal",
|
||||
string? assignedTo = null,
|
||||
string? expectedFrom = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status/state of a task.
|
||||
/// Enforces CanChangeState rules (only iris/bao/nexus-system may change state).
|
||||
/// </summary>
|
||||
Task<TaskBridgeResult<DashboardTaskDto>> UpdateStatusAsync(
|
||||
Guid taskId,
|
||||
string state,
|
||||
string? callerAgent = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Appends an activity entry to a task (comment, status note, agent note).
|
||||
/// Used by agents to annotate their progress on the board.
|
||||
/// </summary>
|
||||
Task<TaskBridgeResult<ActivityEvent>> AppendActivityAsync(
|
||||
Guid taskId,
|
||||
string message,
|
||||
string? type = "comment",
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Handles a task handoff: sets ExpectedFrom to the target agent,
|
||||
/// appends a handoff activity entry, and optionally updates assigned-to.
|
||||
/// </summary>
|
||||
Task<TaskBridgeResult<DashboardTaskDto>> HandoffAsync(
|
||||
Guid taskId,
|
||||
string targetAgent,
|
||||
string? note = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
// ── Query (Read) ──
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full task board state (grouped by status column).
|
||||
/// </summary>
|
||||
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a single task by ID.
|
||||
/// </summary>
|
||||
Task<TaskBridgeResult<DashboardTaskDto>> GetTaskAsync(
|
||||
Guid taskId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all child tasks for a given parent task.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
|
||||
Guid parentTaskId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns task activity history.
|
||||
/// </summary>
|
||||
Task<List<ActivityEvent>> GetTaskActivityAsync(
|
||||
Guid taskId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
// ── Agent Workflow ──
|
||||
|
||||
/// <summary>
|
||||
/// Returns the agent-workflow overview: who is expected to respond,
|
||||
/// stale tasks, workload distribution.
|
||||
/// </summary>
|
||||
Task<AgentWorkflowOverview> GetAgentOverviewAsync(
|
||||
TimeSpan? staleThreshold = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result pattern for task-bridge operations.
|
||||
/// WorkTask? is null on NotFound; state is stored in the Outcome.
|
||||
/// </summary>
|
||||
public sealed record TaskBridgeResult<T>(
|
||||
TaskBridgeOutcome Outcome,
|
||||
T? Data = default,
|
||||
string? Error = null
|
||||
);
|
||||
|
||||
public enum TaskBridgeOutcome
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
InvalidState,
|
||||
Unauthorized,
|
||||
ValidationError
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class LiveUpdateService : ILiveUpdateService
|
||||
{
|
||||
private const int ReplayLimit = 256;
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, Channel<LiveUpdateEnvelope>> _subscribers = new();
|
||||
private readonly object _historyLock = new();
|
||||
private readonly Queue<LiveUpdateEnvelope> _history = new();
|
||||
private long _sequence;
|
||||
|
||||
public long CurrentSequence => Interlocked.Read(ref _sequence);
|
||||
|
||||
public Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default)
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<LiveUpdateEnvelope>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
AllowSynchronousContinuations = false
|
||||
});
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
_subscribers[id] = channel;
|
||||
|
||||
var replay = afterSequence.HasValue ? GetReplay(afterSequence.Value) : Array.Empty<LiveUpdateEnvelope>();
|
||||
foreach (var envelope in replay)
|
||||
{
|
||||
channel.Writer.TryWrite(envelope);
|
||||
}
|
||||
|
||||
ct.Register(() =>
|
||||
{
|
||||
if (_subscribers.TryRemove(id, out var removed))
|
||||
{
|
||||
removed.Writer.TryComplete();
|
||||
}
|
||||
});
|
||||
|
||||
return Task.FromResult(new LiveUpdateSubscription
|
||||
{
|
||||
Reader = channel.Reader,
|
||||
StartingSequence = replay.LastOrDefault()?.Sequence ?? CurrentSequence
|
||||
});
|
||||
}
|
||||
|
||||
public LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard")
|
||||
{
|
||||
var envelope = new LiveUpdateEnvelope(
|
||||
type,
|
||||
DateTimeOffset.UtcNow,
|
||||
payload,
|
||||
Interlocked.Increment(ref _sequence),
|
||||
channel);
|
||||
|
||||
lock (_historyLock)
|
||||
{
|
||||
_history.Enqueue(envelope);
|
||||
while (_history.Count > ReplayLimit)
|
||||
{
|
||||
_history.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (id, subscriber) in _subscribers)
|
||||
{
|
||||
if (!subscriber.Writer.TryWrite(envelope) && _subscribers.TryRemove(id, out var removed))
|
||||
{
|
||||
removed.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
|
||||
return envelope;
|
||||
}
|
||||
|
||||
private LiveUpdateEnvelope[] GetReplay(long afterSequence)
|
||||
{
|
||||
lock (_historyLock)
|
||||
{
|
||||
return _history.Where(item => item.Sequence > afterSequence).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class NotificationService(NexusDbContext db) : INotificationService
|
||||
public sealed class NotificationService(NexusDbContext db, ILiveUpdateService liveUpdateService) : INotificationService
|
||||
{
|
||||
public async Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
|
||||
{
|
||||
@@ -18,6 +18,7 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic
|
||||
};
|
||||
db.Notifications.Add(notification);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await PublishSnapshotAsync(notification.ForUser, ct);
|
||||
return notification;
|
||||
}
|
||||
|
||||
@@ -42,14 +43,17 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic
|
||||
|
||||
notification.IsRead = true;
|
||||
await db.SaveChangesAsync(ct);
|
||||
await PublishSnapshotAsync(notification.ForUser, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default)
|
||||
{
|
||||
var normalizedUser = forUser.ToLowerInvariant();
|
||||
var count = await db.Notifications
|
||||
.Where(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead)
|
||||
.Where(n => n.ForUser == normalizedUser && !n.IsRead)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true), ct);
|
||||
await PublishSnapshotAsync(normalizedUser, ct);
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -58,4 +62,25 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic
|
||||
return await db.Notifications
|
||||
.CountAsync(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead, ct);
|
||||
}
|
||||
|
||||
public async Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||
{
|
||||
var normalizedUser = forUser.ToLowerInvariant();
|
||||
var notifications = await GetForUserAsync(normalizedUser, limit, unreadOnly, ct);
|
||||
var unreadCount = await GetUnreadCountAsync(normalizedUser, ct);
|
||||
return new NotificationSnapshotDto(
|
||||
notifications.Select(MapToDto).ToList(),
|
||||
unreadCount,
|
||||
normalizedUser);
|
||||
}
|
||||
|
||||
private async Task PublishSnapshotAsync(string forUser, CancellationToken ct)
|
||||
{
|
||||
var snapshot = await GetSnapshotAsync(forUser, ct: ct);
|
||||
liveUpdateService.Publish("notifications.snapshot", snapshot, "notifications");
|
||||
}
|
||||
|
||||
private static NotificationDto MapToDto(Notification n) => new(
|
||||
n.Id, n.Type, n.Title, n.Message,
|
||||
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete implementation of ITaskBridgeService.
|
||||
/// Wraps ITaskService, IActivityRepository, INotificationService, and ILiveUpdateService
|
||||
/// into structured, predictable commands for agent-facing usage.
|
||||
///
|
||||
/// All operations produce typed TaskBridgeResult<T> with explicit error codes,
|
||||
/// making agent consumption safe and debuggable.
|
||||
/// </summary>
|
||||
public sealed class TaskBridgeService(
|
||||
ITaskService taskService,
|
||||
IActivityRepository activityRepo,
|
||||
INotificationService notificationService,
|
||||
ILiveUpdateService liveUpdateService) : ITaskBridgeService
|
||||
{
|
||||
private static readonly HashSet<string> ValidStates =
|
||||
new(TaskStateHelper.AllStates, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// ──────────────────────────────── Create Task ────────────────────────────────
|
||||
|
||||
public async Task<TaskBridgeResult<DashboardTaskDto>> CreateTaskAsync(
|
||||
string title,
|
||||
string? detail = null,
|
||||
string? source = "iris",
|
||||
string? priority = "Normal",
|
||||
string? assignedTo = null,
|
||||
Guid? projectId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return Error<DashboardTaskDto>(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);
|
||||
|
||||
var dto = MapToDto(task);
|
||||
return Success(dto);
|
||||
}
|
||||
|
||||
// ──────────────────────────────── Create Child Task ──────────────────────────
|
||||
|
||||
public async Task<TaskBridgeResult<DashboardTaskDto>> CreateChildTaskAsync(
|
||||
Guid parentTaskId,
|
||||
string title,
|
||||
string? detail = null,
|
||||
string? source = "iris",
|
||||
string? priority = "Normal",
|
||||
string? assignedTo = null,
|
||||
string? expectedFrom = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
|
||||
|
||||
// Verify parent exists
|
||||
var parent = await taskService.GetByIdAsync(parentTaskId, ct);
|
||||
if (parent is null)
|
||||
return Error<DashboardTaskDto>(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);
|
||||
|
||||
// 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 dto = MapToDto(task);
|
||||
return Success(dto);
|
||||
}
|
||||
|
||||
// ──────────────────────────────── Update Status ──────────────────────────────
|
||||
|
||||
public async Task<TaskBridgeResult<DashboardTaskDto>> UpdateStatusAsync(
|
||||
Guid taskId,
|
||||
string state,
|
||||
string? callerAgent = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!ValidStates.Contains(state))
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError,
|
||||
$"Invalid state '{state}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}");
|
||||
|
||||
var task = await taskService.GetByIdAsync(taskId, ct);
|
||||
if (task is null)
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
|
||||
|
||||
// Check authorization
|
||||
if (!TaskStateHelper.CanChangeState(callerAgent, task))
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.Unauthorized,
|
||||
$"Agent '{callerAgent}' is not authorized to change task state. Only iris and bao may move tasks.");
|
||||
|
||||
var result = await taskService.UpdateStatusAsync(taskId, state, ct);
|
||||
if (result.Outcome != TaskOperationOutcome.Success)
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.InvalidState, "Status update rejected.");
|
||||
|
||||
var dto = MapToDto(result.Task!);
|
||||
return Success(dto);
|
||||
}
|
||||
|
||||
// ──────────────────────────────── Append Activity ────────────────────────────
|
||||
|
||||
public async Task<TaskBridgeResult<ActivityEvent>> AppendActivityAsync(
|
||||
Guid taskId,
|
||||
string message,
|
||||
string? type = "comment",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
return Error<ActivityEvent>(TaskBridgeOutcome.ValidationError, "Message is required.");
|
||||
|
||||
var task = await taskService.GetByIdAsync(taskId, ct);
|
||||
if (task is null)
|
||||
return Error<ActivityEvent>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
|
||||
|
||||
var ev = new ActivityEvent
|
||||
{
|
||||
Type = type ?? "comment",
|
||||
Message = message.Trim(),
|
||||
TaskId = taskId
|
||||
};
|
||||
|
||||
await activityRepo.AddAsync(ev, ct);
|
||||
|
||||
// Trigger live update so the board refreshes
|
||||
var board = await taskService.GetBoardAsync(ct);
|
||||
liveUpdateService.Publish("tasks.board.snapshot", board);
|
||||
|
||||
return Success(ev);
|
||||
}
|
||||
|
||||
// ──────────────────────────────── Handoff ────────────────────────────────────
|
||||
|
||||
public async Task<TaskBridgeResult<DashboardTaskDto>> HandoffAsync(
|
||||
Guid taskId,
|
||||
string targetAgent,
|
||||
string? note = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetAgent))
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Target agent is required.");
|
||||
|
||||
var task = await taskService.GetByIdAsync(taskId, ct);
|
||||
if (task is null)
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
|
||||
|
||||
var normalizedTarget = targetAgent.Trim().ToLowerInvariant();
|
||||
var handoffNote = string.IsNullOrWhiteSpace(note)
|
||||
? $"Handoff → {normalizedTarget}"
|
||||
: $"Handoff → {normalizedTarget}: {note.Trim()}";
|
||||
|
||||
// Update expected-from and optionally assigned-to
|
||||
task.ExpectedFrom = normalizedTarget;
|
||||
|
||||
// If this is a child task (has parent), keep assigned-to on the child
|
||||
// If standalone, set assigned-to to the target
|
||||
if (!task.ParentTaskId.HasValue)
|
||||
task.AssignedTo = normalizedTarget;
|
||||
|
||||
await taskService.UpdateDashboardTaskAsync(
|
||||
taskId, title: null, detail: null, source: null,
|
||||
priority: null, assignedTo: task.AssignedTo, dueDate: null, ct);
|
||||
|
||||
// Append handoff activity
|
||||
await AppendActivityAsync(taskId, handoffNote, "handoff", ct);
|
||||
|
||||
// Notify the target
|
||||
await notificationService.CreateAsync(
|
||||
"task_assigned",
|
||||
$"Handoff: {task.Title}",
|
||||
handoffNote,
|
||||
normalizedTarget,
|
||||
task.Id,
|
||||
ct);
|
||||
|
||||
var dto = MapToDto(task);
|
||||
return Success(dto);
|
||||
}
|
||||
|
||||
// ──────────────────────────────── Query ──────────────────────────────────────
|
||||
|
||||
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
|
||||
=> await taskService.GetBoardAsync(ct);
|
||||
|
||||
public async Task<TaskBridgeResult<DashboardTaskDto>> GetTaskAsync(
|
||||
Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
var dto = await taskService.GetDashboardTaskByIdAsync(taskId, ct);
|
||||
return dto is null
|
||||
? Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.")
|
||||
: Success(dto);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
|
||||
Guid parentTaskId, CancellationToken ct = default)
|
||||
{
|
||||
var children = await taskService.GetChildTasksAsync(parentTaskId, ct);
|
||||
return children.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
|
||||
Guid taskId, CancellationToken ct = default)
|
||||
=> await taskService.GetTaskActivityAsync(taskId, ct);
|
||||
|
||||
public async Task<AgentWorkflowOverview> GetAgentOverviewAsync(
|
||||
TimeSpan? staleThreshold = null, CancellationToken ct = default)
|
||||
{
|
||||
var threshold = staleThreshold ?? TimeSpan.FromHours(2);
|
||||
return await taskService.GetAgentWorkflowOverviewAsync(threshold, ct);
|
||||
}
|
||||
|
||||
// ──────────────────────────────── Helpers ────────────────────────────────────
|
||||
|
||||
private static TaskBridgeResult<T> Success<T>(T data) =>
|
||||
new(TaskBridgeOutcome.Success, data);
|
||||
|
||||
private static TaskBridgeResult<T> Error<T>(TaskBridgeOutcome outcome, string error) =>
|
||||
new(outcome, Data: default, Error: error);
|
||||
|
||||
private static string NormalizeSource(string? source) =>
|
||||
string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant();
|
||||
|
||||
private static string? NormalizeAssignedTo(string? assignedTo)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
|
||||
var valid = new HashSet<string> { "bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor" };
|
||||
var lower = assignedTo.Trim().ToLowerInvariant();
|
||||
return valid.Contains(lower) ? lower : null;
|
||||
}
|
||||
|
||||
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,
|
||||
t.IsAgentTask, t.ExpectedFrom);
|
||||
}
|
||||
+102
-94
@@ -9,7 +9,8 @@ public sealed class TaskService(
|
||||
ITaskRepository taskRepo,
|
||||
IActivityRepository activityRepo,
|
||||
INotificationService notificationService,
|
||||
IHttpContextAccessor httpContextAccessor) : ITaskService
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILiveUpdateService liveUpdateService) : ITaskService
|
||||
{
|
||||
private static readonly HashSet<string> ValidAssignees =
|
||||
["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"];
|
||||
@@ -22,11 +23,12 @@ public sealed class TaskService(
|
||||
|
||||
public async Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
var allTasks = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
var task = allTasks.FirstOrDefault(t => t.Id == id);
|
||||
if (task is null) return null;
|
||||
|
||||
var activity = await activityRepo.GetRecentForTasksAsync([task.Id], ct);
|
||||
return MapToDtoWithActivity(task, activity);
|
||||
var activity = await activityRepo.GetRecentForTasksAsync(allTasks.Select(t => t.Id), ct);
|
||||
return MapToDtoWithChildren(task, allTasks, activity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
||||
@@ -42,6 +44,7 @@ public sealed class TaskService(
|
||||
};
|
||||
await taskRepo.AddAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created", TaskId = task.Id }, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -56,6 +59,7 @@ public sealed class TaskService(
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved", TaskId = task.Id }, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
@@ -70,6 +74,7 @@ public sealed class TaskService(
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog", TaskId = task.Id }, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
@@ -81,7 +86,6 @@ public sealed class TaskService(
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
// Enforce workflow rules
|
||||
var caller = ResolveCaller();
|
||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
@@ -90,6 +94,7 @@ public sealed class TaskService(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -112,13 +117,14 @@ public sealed class TaskService(
|
||||
}
|
||||
if (request.ProjectId.HasValue)
|
||||
{
|
||||
changes.Add($"Projekt-ID geändert");
|
||||
changes.Add("Projekt-ID geändert");
|
||||
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
|
||||
}
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" aktualisiert: {changeSummary}", TaskId = task.Id }, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
@@ -132,11 +138,10 @@ public sealed class TaskService(
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted", TaskId = task.Id }, ct);
|
||||
await taskRepo.DeleteAsync(task, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success);
|
||||
}
|
||||
|
||||
// ── Dashboard-facing operations ──
|
||||
|
||||
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
|
||||
{
|
||||
var all = await taskRepo.GetAllAsync(ct);
|
||||
@@ -145,10 +150,6 @@ public sealed class TaskService(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns agent-tasks that are still open and where an agent is expected to respond.
|
||||
/// Iris Dashboard uses this to see who she is waiting for.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default)
|
||||
{
|
||||
var all = await taskRepo.GetAllAsync(ct);
|
||||
@@ -159,22 +160,15 @@ public sealed class TaskService(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns agent-tasks grouped by which agent is expected to respond,
|
||||
/// with stale-detection: parent tasks that remain in progress while child work
|
||||
/// is active, and any in-progress task that has not been updated within the stale threshold.
|
||||
/// </summary>
|
||||
public async Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
{
|
||||
var all = await taskRepo.GetAllAsync(ct);
|
||||
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||
|
||||
var agentTasks = all.Where(t => t.IsAgentTask).ToList();
|
||||
|
||||
var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct);
|
||||
|
||||
List<DashboardTaskDto> map(IEnumerable<WorkTask> tasks)
|
||||
=> tasks.Select(task => MapToDtoWithActivity(task, activity)).ToList();
|
||||
=> tasks.Select(task => MapToDtoWithChildren(task, all, activity)).ToList();
|
||||
|
||||
var waitingForBao = map(agentTasks
|
||||
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
|
||||
@@ -193,19 +187,15 @@ public sealed class TaskService(
|
||||
}));
|
||||
|
||||
var staleTasks = map(agentTasks
|
||||
.Where(t =>
|
||||
string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) &&
|
||||
t.UpdatedAt < threshold));
|
||||
.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold));
|
||||
|
||||
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers,
|
||||
staleTasks, staleThreshold);
|
||||
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers, staleTasks, staleThreshold);
|
||||
}
|
||||
|
||||
public async Task<WorkTask> CreateDashboardTaskAsync(
|
||||
string title, string? detail, string? source, string? priority,
|
||||
string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default)
|
||||
{
|
||||
// Validate parent task exists if specified
|
||||
if (parentTaskId.HasValue)
|
||||
{
|
||||
var parent = await taskRepo.GetByIdAsync(parentTaskId.Value, ct);
|
||||
@@ -215,6 +205,7 @@ public sealed class TaskService(
|
||||
|
||||
var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
|
||||
var normalizedAssignee = ValidateAssignedTo(assignedTo);
|
||||
var isVisibleDelegation = parentTaskId.HasValue;
|
||||
|
||||
var task = new WorkTask
|
||||
{
|
||||
@@ -224,16 +215,24 @@ public sealed class TaskService(
|
||||
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
|
||||
AssignedTo = normalizedAssignee,
|
||||
ParentTaskId = parentTaskId,
|
||||
IsAgentTask = parentTaskId.HasValue
|
||||
IsAgentTask = isVisibleDelegation
|
||||
};
|
||||
await taskRepo.AddAsync(task, ct);
|
||||
|
||||
var message = $"Task \"{task.Title}\" created ({task.Source})";
|
||||
var activityMessages = new List<string> { $"Task \"{task.Title}\" created ({task.Source})" };
|
||||
if (parentTaskId.HasValue)
|
||||
message += $" [child of {parentTaskId.Value}]";
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = message, TaskId = task.Id }, ct);
|
||||
{
|
||||
activityMessages.Add($"Sichtbare Delegation erstellt: Child-Task von {parentTaskId.Value}.");
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "delegation",
|
||||
Message = $"Board-first Delegation: Child-Task \"{task.Title}\" für {normalizedAssignee ?? task.Source} sichtbar angelegt.",
|
||||
TaskId = parentTaskId.Value
|
||||
}, ct);
|
||||
}
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = string.Join(" ", activityMessages), TaskId = task.Id }, ct);
|
||||
|
||||
// Auto-notify: if assigned to bao, create a task_assigned notification
|
||||
if (string.Equals(normalizedAssignee, "bao", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await notificationService.CreateAsync(
|
||||
@@ -245,6 +244,7 @@ public sealed class TaskService(
|
||||
ct);
|
||||
}
|
||||
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -252,13 +252,12 @@ public sealed class TaskService(
|
||||
string title, string? detail, string? source, string? priority,
|
||||
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default)
|
||||
{
|
||||
var normalizedExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
|
||||
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
|
||||
|
||||
task.IsAgentTask = true;
|
||||
task.ExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
|
||||
task.ExpectedFrom = normalizedExpectedFrom;
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||
|
||||
// Persist the agent-task-specific fields
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
@@ -268,7 +267,16 @@ public sealed class TaskService(
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
|
||||
// Notify iris about new agent-task
|
||||
if (parentTaskId.HasValue)
|
||||
{
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "delegation",
|
||||
Message = $"Parent-/Child-Delegation sichtbar: Parent {parentTaskId.Value}, Child {task.Id}, wartet auf {task.ExpectedFrom ?? task.AssignedTo ?? "unbekannt"}.",
|
||||
TaskId = parentTaskId.Value
|
||||
}, ct);
|
||||
}
|
||||
|
||||
await notificationService.CreateAsync(
|
||||
"agent_task_created",
|
||||
$"Neuer Agent-Task: {task.Title}",
|
||||
@@ -277,6 +285,7 @@ public sealed class TaskService(
|
||||
task.Id,
|
||||
ct);
|
||||
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -320,13 +329,10 @@ public sealed class TaskService(
|
||||
task.AssignedTo = validated;
|
||||
}
|
||||
}
|
||||
if (dueDate.HasValue)
|
||||
if (dueDate.HasValue && task.DueDate?.Date != dueDate.Value.Date)
|
||||
{
|
||||
if (task.DueDate?.Date != dueDate.Value.Date)
|
||||
{
|
||||
changes.Add($"Fällig: {task.DueDate?.ToString("yyyy-MM-dd") ?? "kein Datum"} → {dueDate.Value:yyyy-MM-dd}");
|
||||
task.DueDate = dueDate;
|
||||
}
|
||||
changes.Add($"Fällig: {task.DueDate?.ToString("yyyy-MM-dd") ?? "kein Datum"} → {dueDate.Value:yyyy-MM-dd}");
|
||||
task.DueDate = dueDate;
|
||||
}
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
@@ -339,18 +345,18 @@ public sealed class TaskService(
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
|
||||
// Notification: wenn Bao die Task geändert hat, Iris benachrichtigen
|
||||
if (changes.Count > 0 && caller == "bao")
|
||||
{
|
||||
await notificationService.CreateAsync(
|
||||
"task_content_changed",
|
||||
$"Bao hat \"{task.Title}\" geändert",
|
||||
$"{changeSummary}",
|
||||
changeSummary,
|
||||
"iris",
|
||||
task.Id,
|
||||
ct);
|
||||
}
|
||||
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
@@ -362,7 +368,6 @@ public sealed class TaskService(
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
// Enforce workflow rules
|
||||
var caller = ResolveCaller();
|
||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
@@ -372,6 +377,7 @@ public sealed class TaskService(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -383,6 +389,7 @@ public sealed class TaskService(
|
||||
task.State = "Done";
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue", TaskId = task.Id }, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
@@ -401,14 +408,15 @@ public sealed class TaskService(
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}", TaskId = task.Id }, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
// ── Board operations ──
|
||||
|
||||
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
|
||||
{
|
||||
var all = await taskRepo.GetAllAsync(ct);
|
||||
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||
|
||||
var offen = new List<DashboardTaskDto>();
|
||||
var inProgress = new List<DashboardTaskDto>();
|
||||
var review = new List<DashboardTaskDto>();
|
||||
@@ -417,21 +425,15 @@ public sealed class TaskService(
|
||||
|
||||
foreach (var task in all)
|
||||
{
|
||||
var dto = MapToDto(task);
|
||||
var dto = MapToDtoWithChildren(task, all, activity);
|
||||
switch (task.State.ToLowerInvariant())
|
||||
{
|
||||
case "backlog":
|
||||
offen.Add(dto); break;
|
||||
case "in progress":
|
||||
inProgress.Add(dto); break;
|
||||
case "review":
|
||||
review.Add(dto); break;
|
||||
case "blocked":
|
||||
blocked.Add(dto); break;
|
||||
case "done":
|
||||
done.Add(dto); break;
|
||||
default:
|
||||
offen.Add(dto); break;
|
||||
case "backlog": offen.Add(dto); break;
|
||||
case "in progress": inProgress.Add(dto); break;
|
||||
case "review": review.Add(dto); break;
|
||||
case "blocked": blocked.Add(dto); break;
|
||||
case "done": done.Add(dto); break;
|
||||
default: offen.Add(dto); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +446,12 @@ public sealed class TaskService(
|
||||
return new BoardResponse(offen, inProgress, review, blocked, done);
|
||||
}
|
||||
|
||||
private async Task PublishBoardSnapshotAsync(CancellationToken ct = default)
|
||||
{
|
||||
var board = await GetBoardAsync(ct);
|
||||
liveUpdateService.Publish("tasks.board.snapshot", board, "board");
|
||||
}
|
||||
|
||||
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
|
||||
{
|
||||
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
|
||||
@@ -461,23 +469,14 @@ public sealed class TaskService(
|
||||
|
||||
public async Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default)
|
||||
{
|
||||
// Resolve canonical state: accept board group keys or canonical strings
|
||||
var canonical = TaskStateHelper.AllStates
|
||||
.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (canonical is null)
|
||||
{
|
||||
// Try mapping from board group key
|
||||
canonical = TaskStateHelper.BoardGroupToState(newState);
|
||||
}
|
||||
|
||||
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase))
|
||||
?? TaskStateHelper.BoardGroupToState(newState);
|
||||
if (canonical is null)
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
// Enforce workflow rules
|
||||
var caller = ResolveCaller();
|
||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
@@ -486,6 +485,7 @@ public sealed class TaskService(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -499,9 +499,7 @@ public sealed class TaskService(
|
||||
{
|
||||
var all = await taskRepo.GetAllAsync(ct);
|
||||
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||
var staleTasks = all.Where(t =>
|
||||
string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) &&
|
||||
t.UpdatedAt < threshold).ToList();
|
||||
var staleTasks = all.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold).ToList();
|
||||
|
||||
foreach (var task in staleTasks)
|
||||
{
|
||||
@@ -516,6 +514,9 @@ public sealed class TaskService(
|
||||
}, ct);
|
||||
}
|
||||
|
||||
if (staleTasks.Count > 0)
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
|
||||
return staleTasks.Count;
|
||||
}
|
||||
|
||||
@@ -533,12 +534,31 @@ public sealed class TaskService(
|
||||
return all.Where(e => e.TaskId == taskId).ToList();
|
||||
}
|
||||
|
||||
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity)
|
||||
{
|
||||
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
|
||||
.OrderByDescending(t => t.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList();
|
||||
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var dto = MapToDtoWithActivity(task, activity, allTasks);
|
||||
return dto with
|
||||
{
|
||||
ChildTasks = childDtos,
|
||||
ChildTaskCount = childDtos.Count,
|
||||
OpenChildTaskCount = openChildTaskCount,
|
||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
t.IsAgentTask, t.ExpectedFrom);
|
||||
|
||||
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity)
|
||||
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity, IReadOnlyList<WorkTask>? _allTasks = null)
|
||||
{
|
||||
var last = activity
|
||||
.Where(e => e.TaskId == t.Id)
|
||||
@@ -550,13 +570,13 @@ public sealed class TaskService(
|
||||
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||
t.IsAgentTask, t.ExpectedFrom,
|
||||
last?.Message,
|
||||
last?.CreatedAt);
|
||||
last?.CreatedAt,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
t.ParentTaskId.HasValue || t.IsAgentTask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates AssignedTo — only recognized agent values are accepted.
|
||||
/// Returns null for invalid values.
|
||||
/// </summary>
|
||||
private static string? ValidateAssignedTo(string? assignedTo)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
|
||||
@@ -564,15 +584,10 @@ public sealed class TaskService(
|
||||
return ValidAssignees.Contains(lower) ? lower : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the caller identity from the HTTP context.
|
||||
/// Reads the X-Agent-Id header for agent calls, falls back to JWT name.
|
||||
/// Outside HTTP context → "nexus-system" (allowed for internal Cron/ResetStale ops).
|
||||
/// </summary>
|
||||
private string ResolveCaller()
|
||||
{
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext is null) return "nexus-system"; // internal system ops allowed
|
||||
if (httpContext is null) return "nexus-system";
|
||||
|
||||
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||
@@ -583,12 +598,6 @@ public sealed class TaskService(
|
||||
return nameClaim?.ToLowerInvariant() ?? "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates status-change notifications when a task moves to a new state.
|
||||
/// - Wenn Bao ändert → Iris benachrichtigen
|
||||
/// - Wenn Iris ändert → Bao benachrichtigen
|
||||
/// - Review/Blocked bekommen spezifische Töne
|
||||
/// </summary>
|
||||
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct)
|
||||
{
|
||||
var caller = ResolveCaller();
|
||||
@@ -615,7 +624,6 @@ public sealed class TaskService(
|
||||
}
|
||||
else
|
||||
{
|
||||
// Allgemeine Statusänderung: Gegenüber benachrichtigen
|
||||
if (caller == "bao")
|
||||
{
|
||||
await notificationService.CreateAsync(
|
||||
|
||||
Reference in New Issue
Block a user