using Nexus.Api.Data; using Nexus.Api.DTOs; using Nexus.Api.Models; using Nexus.Api.Repositories; namespace Nexus.Api.Services; /// /// Concrete implementation of ITaskBridgeService. /// Wraps ITaskService, IActivityRepository, INotificationService, and ILiveUpdateService /// into structured, predictable commands for agent-facing usage. /// /// All operations produce typed TaskBridgeResult with explicit error codes, /// making agent consumption safe and debuggable. /// public sealed class TaskBridgeService( ITaskService taskService, IAgentService agentService, IActivityRepository activityRepo, INotificationService notificationService, ILiveUpdateService liveUpdateService) : ITaskBridgeService { private static readonly HashSet ValidStates = new(TaskStateHelper.AllStates, StringComparer.OrdinalIgnoreCase); // ──────────────────────────────── Create Task ──────────────────────────────── public async Task> 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(TaskBridgeOutcome.ValidationError, "Title is required."); var normalizedSource = NormalizeSource(source); var task = await taskService.CreateDashboardTaskAsync( title.Trim(), detail?.Trim(), normalizedSource, priority, assignedTo, parentTaskId: null, ct); var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task); return Success(dto); } // ──────────────────────────────── Create Child Task ────────────────────────── public async Task> CreateChildTaskAsync( Guid parentTaskId, string title, string? detail = null, string? source = "iris", string? priority = "Normal", string? assignedTo = null, string? expectedFrom = null, bool startsInProgress = false, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(title)) return Error(TaskBridgeOutcome.ValidationError, "Title is required."); // Verify parent exists var parent = await taskService.GetByIdAsync(parentTaskId, ct); if (parent is null) return Error(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found."); var task = await taskService.CreateAgentTaskAsync( title.Trim(), detail?.Trim(), NormalizeSource(source), priority, assignedTo, expectedFrom, parentTaskId, startsInProgress, null, ct); // If parent was in Backlog, move it to InProgress (coordination starts) if (string.Equals(parent.State, "Backlog", StringComparison.OrdinalIgnoreCase)) { var parentTransition = await taskService.StartCoordinationAsync(parentTaskId, ct); if (parentTransition.Outcome != TaskOperationOutcome.Success) { return Error( TaskBridgeOutcome.InvalidState, $"Parent task {parentTaskId} could not be moved to In progress for coordination."); } } var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task); return Success(dto); } // ──────────────────────────────── Update Status ────────────────────────────── public async Task> UpdateStatusAsync( Guid taskId, string state, string? callerAgent = null, CancellationToken ct = default) { if (!ValidStates.Contains(state)) return Error(TaskBridgeOutcome.ValidationError, $"Invalid state '{state}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}"); var task = await taskService.GetByIdAsync(taskId, ct); if (task is null) return Error(TaskBridgeOutcome.NotFound, $"Task {taskId} not found."); // Check authorization if (!TaskStateHelper.CanChangeState(callerAgent, task)) return Error(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(TaskBridgeOutcome.InvalidState, "Status update rejected."); var dto = await taskService.GetDashboardTaskByIdAsync(result.Task!.Id, ct) ?? MapToDto(result.Task); return Success(dto); } // ──────────────────────────────── Append Activity ──────────────────────────── public async Task> AppendActivityAsync( Guid taskId, string message, string? type = "comment", CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(message)) return Error(TaskBridgeOutcome.ValidationError, "Message is required."); var task = await taskService.GetByIdAsync(taskId, ct); if (task is null) return Error(TaskBridgeOutcome.NotFound, $"Task {taskId} not found."); var ev = new ActivityEvent { Type = type ?? "comment", Message = message.Trim(), TaskId = taskId }; await activityRepo.AddAsync(ev, ct); // Keep the legacy dashboard stream as an invalidation adapter without // rebuilding every task and activity row in this mutation request. liveUpdateService.Publish( "tasks.board.snapshot", new { taskId }, "board"); return Success(ev); } // ──────────────────────────────── Handoff ──────────────────────────────────── public async Task> HandoffAsync( Guid taskId, string targetAgent, string? note = null, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(targetAgent)) return Error(TaskBridgeOutcome.ValidationError, "Target agent is required."); var task = await taskService.GetByIdAsync(taskId, ct); if (task is null) return Error(TaskBridgeOutcome.NotFound, $"Task {taskId} not found."); var normalizedTarget = await NormalizeActorAsync(targetAgent, ct); if (normalizedTarget is null) return Error(TaskBridgeOutcome.ValidationError, $"Unknown target agent '{targetAgent}'."); var handoffNote = string.IsNullOrWhiteSpace(note) ? $"Handoff → {normalizedTarget}" : $"Handoff → {normalizedTarget}: {note.Trim()}"; // 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 = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task); return Success(dto); } // ──────────────────────────────── Query ────────────────────────────────────── public async Task GetBoardAsync(CancellationToken ct = default) => await taskService.GetBoardAsync(ct); public async Task> GetTaskAsync( Guid taskId, CancellationToken ct = default) { var dto = await taskService.GetDashboardTaskByIdAsync(taskId, ct); return dto is null ? Error(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.") : Success(dto); } public async Task> GetChildTasksAsync( Guid parentTaskId, CancellationToken ct = default) { var board = await taskService.GetBoardAsync(ct); return FlattenBoard(board) .Where(task => task.ParentTaskId == parentTaskId) .OrderByDescending(task => task.UpdatedAt) .ToList(); } public async Task> GetTaskActivityAsync( Guid taskId, CancellationToken ct = default) => await taskService.GetTaskActivityAsync(taskId, ct); public async Task GetAgentOverviewAsync( TimeSpan? staleThreshold = null, CancellationToken ct = default) { var threshold = staleThreshold ?? TimeSpan.FromHours(2); return await taskService.GetAgentWorkflowOverviewAsync(threshold, ct); } // ──────────────────────────────── Helpers ──────────────────────────────────── private static TaskBridgeResult Success(T data) => new(TaskBridgeOutcome.Success, data); private static TaskBridgeResult Error(TaskBridgeOutcome outcome, string error) => new(outcome, Data: default, Error: error); private static string NormalizeSource(string? source) => string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant(); private async Task NormalizeActorAsync(string? actorId, CancellationToken ct) { var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct)); return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors); } private static IEnumerable FlattenBoard(BoardResponse board) => board.Offen .Concat(board.InProgress) .Concat(board.Review) .Concat(board.Blocked) .Concat(board.Done); private static DashboardTaskDto MapToDto(WorkTask t) => new( t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt, t.IsAgentTask, t.ExpectedFrom, ProjectId: t.ProjectId); }