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:
@@ -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