feat: ship agent progress visibility
CI - Build & Test / Backend (.NET) (push) Failing after 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 4s

This commit is contained in:
2026-06-20 20:22:54 +02:00
parent 3dd745586b
commit adae7ba26d
9 changed files with 202 additions and 45 deletions
+2 -2
View File
@@ -262,9 +262,9 @@ public class DashboardController(
[HttpGet("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
{
var task = await taskService.GetByIdAsync(id, ct);
var task = await taskService.GetDashboardTaskByIdAsync(id, ct);
if (task is null) return NotFound(new { error = "Task not found." });
return Ok(MapToDto(task));
return Ok(task);
}
[HttpGet("tasks/{id:guid}/activity")]
+3 -1
View File
@@ -91,7 +91,9 @@ public sealed record DashboardTaskDto(
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
bool IsAgentTask = false,
string? ExpectedFrom = null
string? ExpectedFrom = null,
string? LastActivityMessage = null,
DateTimeOffset? LastActivityAt = null
);
public sealed record CreateDashboardTaskRequest(
@@ -8,6 +8,18 @@ public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
{
var ids = taskIds.Distinct().ToList();
if (ids.Count == 0)
return Task.FromResult(new List<ActivityEvent>());
return db.Activity.AsNoTracking()
.Where(x => x.TaskId.HasValue && ids.Contains(x.TaskId.Value))
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(ct);
}
public async Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
{
@@ -5,6 +5,7 @@ namespace Nexus.Api.Repositories;
public interface IActivityRepository
{
Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default);
Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default);
Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default);
+1
View File
@@ -36,6 +36,7 @@ public interface ITaskService
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
// Agent Workflow Overview
Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default);
+37 -16
View File
@@ -20,6 +20,15 @@ public sealed class TaskService(
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await taskRepo.GetByIdAsync(id, ct);
public async Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return null;
var activity = await activityRepo.GetRecentForTasksAsync([task.Id], ct);
return MapToDtoWithActivity(task, activity);
}
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
=> await taskRepo.GetPendingApprovalAsync(ct);
@@ -162,35 +171,32 @@ public sealed class TaskService(
var agentTasks = all.Where(t => t.IsAgentTask).ToList();
var waitingForBao = agentTasks
var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct);
List<DashboardTaskDto> map(IEnumerable<WorkTask> tasks)
=> tasks.Select(task => MapToDtoWithActivity(task, activity)).ToList();
var waitingForBao = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.Select(MapToDto)
.ToList();
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
var waitingForIris = agentTasks
var waitingForIris = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "iris", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.Select(MapToDto)
.ToList();
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
var waitingForOthers = agentTasks
var waitingForOthers = map(agentTasks
.Where(t =>
{
var expected = (t.ExpectedFrom ?? "").ToLowerInvariant();
return expected != "bao" && expected != "iris" && !string.IsNullOrWhiteSpace(expected) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase);
})
.Select(MapToDto)
.ToList();
}));
var staleTasks = agentTasks
var staleTasks = map(agentTasks
.Where(t =>
(string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) ||
string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) &&
t.UpdatedAt < threshold)
.Select(MapToDto)
.ToList();
t.UpdatedAt < threshold));
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers,
staleTasks, staleThreshold);
@@ -533,6 +539,21 @@ public sealed class TaskService(
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom);
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity)
{
var last = activity
.Where(e => e.TaskId == t.Id)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefault();
return new DashboardTaskDto(
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,
last?.Message,
last?.CreatedAt);
}
/// <summary>
/// Validates AssignedTo — only recognized agent values are accepted.
/// Returns null for invalid values.