265 lines
11 KiB
C#
265 lines
11 KiB
C#
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,
|
|
IAgentService agentService,
|
|
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 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<TaskBridgeResult<DashboardTaskDto>> 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<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 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<DashboardTaskDto>(
|
|
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<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 = await taskService.GetDashboardTaskByIdAsync(result.Task!.Id, ct) ?? 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 = await NormalizeActorAsync(targetAgent, ct);
|
|
if (normalizedTarget is null)
|
|
return Error<DashboardTaskDto>(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<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 board = await taskService.GetBoardAsync(ct);
|
|
return FlattenBoard(board)
|
|
.Where(task => task.ParentTaskId == parentTaskId)
|
|
.OrderByDescending(task => task.UpdatedAt)
|
|
.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 async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
|
|
{
|
|
var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
|
|
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
|
|
}
|
|
|
|
private static IEnumerable<DashboardTaskDto> FlattenBoard(BoardResponse board)
|
|
=> board.Offen
|
|
.Concat(board.InProgress)
|
|
.Concat(board.Review)
|
|
.Concat(board.Blocked)
|
|
.Concat(board.Done);
|
|
|
|
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
|
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);
|
|
}
|