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);
// 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> 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);
}