Files
nexus/backend/Repositories/TaskRepository.cs
T
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

235 lines
8.7 KiB
C#

using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Models;
using System.Text.Json;
namespace Nexus.Api.Repositories;
public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
{
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> db.Tasks.AsNoTracking().OrderByDescending(x => x.UpdatedAt).ToListAsync(ct);
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> db.Tasks.FindAsync([id], ct);
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
{
var threshold = DateTimeOffset.UtcNow.AddHours(-1);
return db.Tasks.AsNoTracking()
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.InProgress) && x.UpdatedAt <= threshold)
.OrderByDescending(x => x.UpdatedAt)
.ToListAsync(ct);
}
public async Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default)
{
db.Tasks.Add(task);
db.OutboxEvents.Add(CreateTaskEvent("task.created", task));
await db.SaveChangesAsync(ct);
return task;
}
public async Task<bool> TryResetStaleInProgressToBacklogAsync(
Guid id,
DateTimeOffset staleBefore,
DateTimeOffset updatedAt,
CancellationToken ct = default)
{
if (!db.Database.IsRelational())
{
var task = await db.Tasks
.FirstOrDefaultAsync(task => task.Id == id
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
&& task.UpdatedAt < staleBefore, ct);
if (task is null)
{
return false;
}
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
task.UpdatedAt = updatedAt;
db.OutboxEvents.Add(CreateTaskEvent("task.updated", task));
await db.SaveChangesAsync(ct);
return true;
}
await using var transaction = await db.Database.BeginTransactionAsync(ct);
var affectedRows = await db.Tasks
.Where(task => task.Id == id
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
&& task.UpdatedAt < staleBefore)
.ExecuteUpdateAsync(setters => setters
.SetProperty(task => task.State, TaskStateHelper.ToStateString(TaskState.Backlog))
.SetProperty(task => task.UpdatedAt, updatedAt), ct);
if (affectedRows > 0)
{
var updatedTask = await db.Tasks
.AsNoTracking()
.SingleAsync(task => task.Id == id, ct);
db.OutboxEvents.Add(CreateTaskEvent("task.updated", updatedTask));
await db.SaveChangesAsync(ct);
}
await transaction.CommitAsync(ct);
return affectedRows > 0;
}
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
{
task.UpdatedAt = DateTimeOffset.UtcNow;
db.Tasks.Update(task);
db.OutboxEvents.Add(CreateTaskEvent("task.updated", task));
await db.SaveChangesAsync(ct);
}
public async Task DeleteAsync(WorkTask task, CancellationToken ct = default)
{
db.OutboxEvents.Add(CreateTaskEvent("task.deleted", task));
db.Tasks.Remove(task);
await db.SaveChangesAsync(ct);
}
public Task<int> CountAsync(CancellationToken ct = default)
=> db.Tasks.CountAsync(ct);
public Task<int> CountByStateAsync(string state, CancellationToken ct = default)
=> db.Tasks.CountAsync(x => x.State == state, ct);
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default)
=> db.Tasks.AsNoTracking()
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
.OrderByDescending(x => x.UpdatedAt)
.FirstOrDefaultAsync(ct);
public async Task<TaskBoardQueryPage> GetBoardPageAsync(
int doneLimit,
DateTimeOffset? doneBeforeUpdatedAt,
Guid? doneBeforeId,
CancellationToken ct = default)
{
var doneState = TaskStateHelper.ToStateString(TaskState.Done);
var activeQuery = db.Tasks
.AsNoTracking()
.Where(task => task.State != doneState)
.OrderBy(task => task.State == "Backlog" ? 0
: task.State == "In progress" ? 1
: task.State == "Review" ? 2
: task.State == "Blocked" ? 3
: 4)
.ThenByDescending(task => task.Priority == "High" ? 3
: task.Priority == "Medium" || task.Priority == "Normal" ? 2
: task.Priority == "Low" ? 1
: 2)
.ThenBy(task => task.CreatedAt)
.ThenBy(task => task.Id);
var doneQuery = db.Tasks
.AsNoTracking()
.Where(task => task.State == doneState);
if (doneBeforeUpdatedAt.HasValue && doneBeforeId.HasValue)
{
var cursorUpdatedAt = doneBeforeUpdatedAt.Value;
var cursorId = doneBeforeId.Value;
doneQuery = doneQuery.Where(task =>
task.UpdatedAt < cursorUpdatedAt
|| (task.UpdatedAt == cursorUpdatedAt && task.Id.CompareTo(cursorId) < 0));
}
doneQuery = doneQuery
.OrderByDescending(task => task.UpdatedAt)
.ThenByDescending(task => task.Id);
// Three SQL statements total. Child counts and latest activity are
// correlated scalar subqueries inside each projected statement rather
// than per-card round trips.
var isDoneContinuation =
doneBeforeUpdatedAt.HasValue && doneBeforeId.HasValue;
var activeTasks = isDoneContinuation
? []
: await ProjectBoardCards(activeQuery).ToListAsync(ct);
var doneTasks = await ProjectBoardCards(doneQuery)
.Take(doneLimit + 1)
.ToListAsync(ct);
var revision = await db.Tasks
.AsNoTracking()
.OrderByDescending(task => task.UpdatedAt)
.ThenByDescending(task => task.Id)
.Select(task => new TaskBoardRevisionPoint(task.UpdatedAt, task.Id))
.FirstOrDefaultAsync(ct);
var hasMoreDone = doneTasks.Count > doneLimit;
if (hasMoreDone)
doneTasks.RemoveAt(doneLimit);
return new TaskBoardQueryPage(activeTasks, doneTasks, hasMoreDone, revision);
}
public Task<TaskBoardCardDto?> GetBoardCardAsync(
Guid id,
CancellationToken ct = default)
=> ProjectBoardCards(
db.Tasks
.AsNoTracking()
.Where(task => task.Id == id))
.SingleOrDefaultAsync(ct);
private IQueryable<TaskBoardCardDto> ProjectBoardCards(IQueryable<WorkTask> query)
=> query.Select(task => new TaskBoardCardDto(
task.Id,
task.Title,
task.Detail,
task.Source,
task.State,
task.Priority,
task.AssignedTo,
task.ParentTaskId,
task.ProjectId,
task.DueDate,
task.CreatedAt,
task.UpdatedAt,
task.IsAgentTask,
task.ExpectedFrom,
db.Activity
.Where(activity => activity.TaskId == task.Id)
.OrderByDescending(activity => activity.CreatedAt)
.ThenByDescending(activity => activity.Id)
.Select(activity => activity.Message)
.FirstOrDefault(),
db.Activity
.Where(activity => activity.TaskId == task.Id)
.OrderByDescending(activity => activity.CreatedAt)
.ThenByDescending(activity => activity.Id)
.Select(activity => (DateTimeOffset?)activity.CreatedAt)
.FirstOrDefault(),
db.Tasks.Count(child => child.ParentTaskId == task.Id),
db.Tasks.Count(child =>
child.ParentTaskId == task.Id
&& child.State != "Done"),
task.ParentTaskId.HasValue
|| task.IsAgentTask
|| db.Tasks.Any(child => child.ParentTaskId == task.Id)));
private static OutboxEvent CreateTaskEvent(string type, WorkTask task)
=> new()
{
Type = type,
AggregateType = "task",
AggregateId = task.Id.ToString(),
AggregateRevision = 0,
PayloadJson = JsonSerializer.Serialize(new
{
entityType = "task",
id = task.Id,
state = task.State,
projectId = task.ProjectId,
assignedAgentId = task.AssignedTo
})
};
}