feat: ship agent-first mission control v0.2.57
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

This commit is contained in:
AzuTear
2026-07-31 22:39:47 +02:00
parent 3bc7622977
commit f5552218bc
535 changed files with 95242 additions and 8791 deletions
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using System.Text.Json;
namespace Nexus.Api.Repositories;
@@ -58,6 +59,19 @@ public sealed class ActivityRepository(NexusDbContext db, Nexus.Api.Services.ILi
var agentIds = Nexus.Api.Services.AgentActivityText.ExtractAgentIds(activity.Message);
activity.Message = Nexus.Api.Services.AgentActivityText.RedactForDisplay(activity.Message);
db.Activity.Add(activity);
db.OutboxEvents.Add(new OutboxEvent
{
Type = "activity.created",
AggregateType = "activity",
AggregateId = activity.TaskId?.ToString() ?? Guid.NewGuid().ToString(),
AggregateRevision = 0,
PayloadJson = JsonSerializer.Serialize(new
{
type = activity.Type,
taskId = activity.TaskId,
agentIds
})
});
await db.SaveChangesAsync(ct);
liveUpdates.Publish("activity.created", new
{
@@ -0,0 +1,38 @@
using Nexus.Api.Data;
namespace Nexus.Api.Repositories;
public interface IOpenClawConnectionProfileRepository
{
Task<OpenClawConnectionProfile?> GetPrimaryAsync(
CancellationToken cancellationToken = default);
Task<OpenClawConnectionProfile> SavePrimaryAsync(
OpenClawConnectionProfile profile,
int? expectedRevision,
CancellationToken cancellationToken = default);
Task<bool> DeletePrimaryAsync(
int expectedRevision,
CancellationToken cancellationToken = default);
}
public sealed class OpenClawConnectionProfileConcurrencyException : Exception
{
public OpenClawConnectionProfileConcurrencyException(
string message,
int? currentRevision = null)
: base(message)
{
CurrentRevision = currentRevision;
}
public OpenClawConnectionProfileConcurrencyException(
string message,
Exception innerException)
: base(message, innerException)
{
}
public int? CurrentRevision { get; }
}
@@ -0,0 +1,43 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Repositories;
public interface IOpenClawRunRepository
{
Task<IReadOnlyList<OpenClawRun>> GetAsync(OpenClawRunQuery query, CancellationToken cancellationToken = default);
Task<OpenClawRun?> GetByIdAsync(Guid id, bool tracking = false, CancellationToken cancellationToken = default);
Task<OpenClawRun?> GetByStartIdempotencyKeyAsync(string idempotencyKey, CancellationToken cancellationToken = default);
Task<OpenClawRun?> GetByOpenClawRunIdAsync(string openClawRunId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OpenClawRunHistory>> GetHistoryAsync(Guid runId, CancellationToken cancellationToken = default);
Task<OpenClawRunHistory?> GetInvocationAsync(
Guid runId,
string action,
string idempotencyKey,
CancellationToken cancellationToken = default);
Task<bool> HasGatewayEventAsync(string gatewayEventId, CancellationToken cancellationToken = default);
Task<bool> TaskExistsAsync(Guid taskId, CancellationToken cancellationToken = default);
Task<bool> ProjectExistsAsync(Guid projectId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OpenClawRunSubscription>> GetActiveSubscriptionsAsync(
CancellationToken cancellationToken = default);
Task AddAsync(
OpenClawRun run,
OpenClawRunHistory history,
CancellationToken cancellationToken = default);
Task AddRetryAsync(
OpenClawRun source,
OpenClawRun retry,
OpenClawRunHistory sourceHistory,
OpenClawRunHistory retryHistory,
CancellationToken cancellationToken = default);
Task UpdateAsync(
OpenClawRun run,
OpenClawRunHistory history,
CancellationToken cancellationToken = default);
Task UpdateProjectionAsync(
OpenClawRun run,
OpenClawRunHistory? history = null,
CancellationToken cancellationToken = default);
}
public sealed record OpenClawRunSubscription(string SessionKey, string AgentId);
@@ -10,4 +10,7 @@ public interface IProjectRepository
Task UpdateAsync(Project project, CancellationToken ct = default);
Task DeleteAsync(Project project, CancellationToken ct = default);
Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default);
Task<List<WorkTask>> GetTasksAsync(
Guid projectId,
CancellationToken ct = default);
}
+19
View File
@@ -1,4 +1,5 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Repositories;
@@ -14,4 +15,22 @@ public interface ITaskRepository
Task<int> CountAsync(CancellationToken ct = default);
Task<int> CountByStateAsync(string state, CancellationToken ct = default);
Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default);
Task<TaskBoardQueryPage> GetBoardPageAsync(
int doneLimit,
DateTimeOffset? doneBeforeUpdatedAt,
Guid? doneBeforeId,
CancellationToken ct = default);
Task<TaskBoardCardDto?> GetBoardCardAsync(
Guid id,
CancellationToken ct = default);
}
public sealed record TaskBoardQueryPage(
IReadOnlyList<TaskBoardCardDto> ActiveTasks,
IReadOnlyList<TaskBoardCardDto> DoneTasks,
bool HasMoreDone,
TaskBoardRevisionPoint? Revision);
public sealed record TaskBoardRevisionPoint(
DateTimeOffset UpdatedAt,
Guid Id);
@@ -0,0 +1,134 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
namespace Nexus.Api.Repositories;
public sealed class OpenClawConnectionProfileRepository(NexusDbContext db)
: IOpenClawConnectionProfileRepository
{
public Task<OpenClawConnectionProfile?> GetPrimaryAsync(
CancellationToken cancellationToken = default)
=> db.Set<OpenClawConnectionProfile>()
.AsNoTracking()
.SingleOrDefaultAsync(
profile => profile.ProfileId == OpenClawConnectionProfile.PrimaryProfileId,
cancellationToken);
public async Task<OpenClawConnectionProfile> SavePrimaryAsync(
OpenClawConnectionProfile profile,
int? expectedRevision,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
if (!string.Equals(
profile.ProfileId,
OpenClawConnectionProfile.PrimaryProfileId,
StringComparison.Ordinal))
{
throw new ArgumentException("Only the primary OpenClaw profile is supported.", nameof(profile));
}
var profiles = db.Set<OpenClawConnectionProfile>();
var current = await profiles.SingleOrDefaultAsync(
item => item.ProfileId == OpenClawConnectionProfile.PrimaryProfileId,
cancellationToken);
if (current is null)
{
if (expectedRevision is not null and not 0)
{
throw new OpenClawConnectionProfileConcurrencyException(
"The OpenClaw connection profile no longer exists.");
}
profile.Revision = 1;
profile.CreatedAt = profile.CreatedAt == default
? DateTimeOffset.UtcNow
: profile.CreatedAt;
profile.UpdatedAt = DateTimeOffset.UtcNow;
profiles.Add(profile);
await SaveChangesAsync(cancellationToken);
return Clone(profile);
}
if (expectedRevision is null || expectedRevision.Value != current.Revision)
{
throw new OpenClawConnectionProfileConcurrencyException(
"The OpenClaw connection profile changed since it was loaded.",
current.Revision);
}
current.Endpoint = profile.Endpoint;
current.DiscoverySource = profile.DiscoverySource;
current.RequiredVersion = profile.RequiredVersion;
current.TlsCertificateFingerprint = profile.TlsCertificateFingerprint;
current.AdoptionState = profile.AdoptionState;
current.ManagementEnabled = profile.ManagementEnabled;
current.CapabilityHash = profile.CapabilityHash;
current.DeviceId = profile.DeviceId;
current.LastProbedAt = profile.LastProbedAt;
current.LastVerifiedAt = profile.LastVerifiedAt;
current.AdoptedAt = profile.AdoptedAt;
current.UpdatedAt = DateTimeOffset.UtcNow;
current.Revision++;
await SaveChangesAsync(cancellationToken);
return Clone(current);
}
public async Task<bool> DeletePrimaryAsync(
int expectedRevision,
CancellationToken cancellationToken = default)
{
var profiles = db.Set<OpenClawConnectionProfile>();
var current = await profiles.SingleOrDefaultAsync(
item => item.ProfileId == OpenClawConnectionProfile.PrimaryProfileId,
cancellationToken);
if (current is null)
return false;
if (current.Revision != expectedRevision)
{
throw new OpenClawConnectionProfileConcurrencyException(
"The OpenClaw connection profile changed since it was loaded.",
current.Revision);
}
profiles.Remove(current);
await SaveChangesAsync(cancellationToken);
return true;
}
private async Task SaveChangesAsync(CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException exception)
{
throw new OpenClawConnectionProfileConcurrencyException(
"The OpenClaw connection profile changed concurrently.",
innerException: exception);
}
}
private static OpenClawConnectionProfile Clone(OpenClawConnectionProfile profile)
=> new()
{
ProfileId = profile.ProfileId,
Endpoint = profile.Endpoint,
DiscoverySource = profile.DiscoverySource,
RequiredVersion = profile.RequiredVersion,
TlsCertificateFingerprint = profile.TlsCertificateFingerprint,
AdoptionState = profile.AdoptionState,
ManagementEnabled = profile.ManagementEnabled,
CapabilityHash = profile.CapabilityHash,
DeviceId = profile.DeviceId,
Revision = profile.Revision,
CreatedAt = profile.CreatedAt,
UpdatedAt = profile.UpdatedAt,
LastProbedAt = profile.LastProbedAt,
LastVerifiedAt = profile.LastVerifiedAt,
AdoptedAt = profile.AdoptedAt
};
}
@@ -0,0 +1,105 @@
using System.Globalization;
using System.Text;
namespace Nexus.Api.Repositories;
internal readonly record struct OpenClawRunCursorPosition(
DateTimeOffset CreatedAt,
Guid? Id);
internal static class OpenClawRunCursorCodec
{
private const string Version = "v1";
private const int MaximumEncodedLength = 128;
public static string Encode(DateTimeOffset createdAt, Guid id)
{
var payload = string.Create(
CultureInfo.InvariantCulture,
$"{Version}|{createdAt.UtcTicks}|{id:N}");
return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
public static bool TryDecode(
string? cursor,
out OpenClawRunCursorPosition position)
{
position = default;
if (string.IsNullOrWhiteSpace(cursor) || cursor.Length > MaximumEncodedLength)
return false;
// Compatibility with the original run cursor, which was an unversioned
// UTC tick value and therefore cannot express the Id tie-breaker.
if (long.TryParse(
cursor,
NumberStyles.None,
CultureInfo.InvariantCulture,
out var legacyTicks))
{
return TryCreatePosition(legacyTicks, null, out position);
}
try
{
var normalized = cursor
.Replace('-', '+')
.Replace('_', '/');
normalized = (normalized.Length % 4) switch
{
0 => normalized,
2 => normalized + "==",
3 => normalized + "=",
_ => throw new FormatException("Invalid Base64Url length.")
};
var payload = new UTF8Encoding(
encoderShouldEmitUTF8Identifier: false,
throwOnInvalidBytes: true)
.GetString(Convert.FromBase64String(normalized));
var parts = payload.Split('|');
if (parts.Length != 3
|| !string.Equals(parts[0], Version, StringComparison.Ordinal)
|| !long.TryParse(
parts[1],
NumberStyles.None,
CultureInfo.InvariantCulture,
out var utcTicks)
|| !Guid.TryParseExact(parts[2], "N", out var id))
{
return false;
}
return TryCreatePosition(utcTicks, id, out position);
}
catch (Exception exception) when (
exception is FormatException
or DecoderFallbackException)
{
return false;
}
}
private static bool TryCreatePosition(
long utcTicks,
Guid? id,
out OpenClawRunCursorPosition position)
{
position = default;
try
{
position = new OpenClawRunCursorPosition(
new DateTimeOffset(utcTicks, TimeSpan.Zero),
id);
return true;
}
catch (ArgumentOutOfRangeException)
{
return false;
}
}
}
@@ -0,0 +1,202 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Models;
using System.Text.Json;
namespace Nexus.Api.Repositories;
public sealed class OpenClawRunRepository(NexusDbContext db) : IOpenClawRunRepository
{
public async Task<IReadOnlyList<OpenClawRun>> GetAsync(
OpenClawRunQuery query,
CancellationToken cancellationToken = default)
{
var runs = db.OpenClawRuns.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(query.Status))
runs = runs.Where(run => run.Status == query.Status);
if (query.TaskId.HasValue)
runs = runs.Where(run => run.TaskId == query.TaskId);
if (query.ProjectId.HasValue)
runs = runs.Where(run => run.ProjectId == query.ProjectId);
if (!string.IsNullOrWhiteSpace(query.SessionKey))
runs = runs.Where(run => run.SessionKey == query.SessionKey);
if (OpenClawRunCursorCodec.TryDecode(query.Cursor, out var cursor))
{
var createdBefore = cursor.CreatedAt;
if (cursor.Id.HasValue)
{
var idBefore = cursor.Id.Value;
runs = runs.Where(run =>
run.CreatedAt < createdBefore
|| (run.CreatedAt == createdBefore
&& run.Id.CompareTo(idBefore) < 0));
}
else
{
runs = runs.Where(run => run.CreatedAt < createdBefore);
}
}
return await runs
.OrderByDescending(run => run.CreatedAt)
.ThenByDescending(run => run.Id)
.Take(Math.Clamp(query.Limit, 1, 201))
.ToListAsync(cancellationToken);
}
public Task<OpenClawRun?> GetByIdAsync(
Guid id,
bool tracking = false,
CancellationToken cancellationToken = default)
{
var query = tracking
? db.OpenClawRuns.AsQueryable()
: db.OpenClawRuns.AsNoTracking();
return query.FirstOrDefaultAsync(run => run.Id == id, cancellationToken);
}
public Task<OpenClawRun?> GetByStartIdempotencyKeyAsync(
string idempotencyKey,
CancellationToken cancellationToken = default)
=> db.OpenClawRuns
.AsNoTracking()
.FirstOrDefaultAsync(run => run.StartIdempotencyKey == idempotencyKey, cancellationToken);
public Task<OpenClawRun?> GetByOpenClawRunIdAsync(
string openClawRunId,
CancellationToken cancellationToken = default)
=> db.OpenClawRuns
.FirstOrDefaultAsync(run => run.OpenClawRunId == openClawRunId, cancellationToken);
public async Task<IReadOnlyList<OpenClawRunHistory>> GetHistoryAsync(
Guid runId,
CancellationToken cancellationToken = default)
=> await db.OpenClawRunHistory
.AsNoTracking()
.Where(item => item.RunId == runId)
.OrderBy(item => item.OccurredAt)
.ThenBy(item => item.Id)
.ToListAsync(cancellationToken);
public Task<OpenClawRunHistory?> GetInvocationAsync(
Guid runId,
string action,
string idempotencyKey,
CancellationToken cancellationToken = default)
=> db.OpenClawRunHistory
.AsNoTracking()
.FirstOrDefaultAsync(
item => item.RunId == runId
&& item.Action == action
&& item.IdempotencyKey == idempotencyKey,
cancellationToken);
public Task<bool> HasGatewayEventAsync(
string gatewayEventId,
CancellationToken cancellationToken = default)
=> db.OpenClawRunHistory
.AsNoTracking()
.AnyAsync(item => item.GatewayEventId == gatewayEventId, cancellationToken);
public Task<bool> TaskExistsAsync(Guid taskId, CancellationToken cancellationToken = default)
=> db.Tasks.AsNoTracking().AnyAsync(task => task.Id == taskId, cancellationToken);
public Task<bool> ProjectExistsAsync(Guid projectId, CancellationToken cancellationToken = default)
=> db.Projects.AsNoTracking().AnyAsync(project => project.Id == projectId, cancellationToken);
public async Task<IReadOnlyList<OpenClawRunSubscription>> GetActiveSubscriptionsAsync(
CancellationToken cancellationToken = default)
{
var subscriptions = await db.OpenClawRuns
.AsNoTracking()
.Where(run => run.Status == OpenClawRunStates.Dispatching
|| run.Status == OpenClawRunStates.Running
|| run.Status == OpenClawRunStates.Stopping)
.Select(run => new { run.SessionKey, run.AgentId })
.Distinct()
.ToListAsync(cancellationToken);
return subscriptions
.Select(item => new OpenClawRunSubscription(item.SessionKey, item.AgentId))
.ToList();
}
public async Task AddAsync(
OpenClawRun run,
OpenClawRunHistory history,
CancellationToken cancellationToken = default)
{
db.OpenClawRuns.Add(run);
db.OpenClawRunHistory.Add(history);
db.OutboxEvents.Add(CreateRunEvent("run.created", run));
await db.SaveChangesAsync(cancellationToken);
}
public async Task AddRetryAsync(
OpenClawRun source,
OpenClawRun retry,
OpenClawRunHistory sourceHistory,
OpenClawRunHistory retryHistory,
CancellationToken cancellationToken = default)
{
source.UpdatedAt = DateTimeOffset.UtcNow;
source.Revision++;
db.OpenClawRuns.Update(source);
db.OpenClawRuns.Add(retry);
db.OpenClawRunHistory.AddRange(sourceHistory, retryHistory);
db.OutboxEvents.Add(CreateRunEvent("run.updated", source));
db.OutboxEvents.Add(CreateRunEvent(
"run.created",
retry,
source.Id));
await db.SaveChangesAsync(cancellationToken);
}
public async Task UpdateAsync(
OpenClawRun run,
OpenClawRunHistory history,
CancellationToken cancellationToken = default)
{
run.UpdatedAt = DateTimeOffset.UtcNow;
run.Revision++;
db.OpenClawRuns.Update(run);
db.OpenClawRunHistory.Add(history);
db.OutboxEvents.Add(CreateRunEvent("run.updated", run));
await db.SaveChangesAsync(cancellationToken);
}
public async Task UpdateProjectionAsync(
OpenClawRun run,
OpenClawRunHistory? history = null,
CancellationToken cancellationToken = default)
{
run.UpdatedAt = DateTimeOffset.UtcNow;
run.Revision++;
db.OpenClawRuns.Update(run);
if (history is not null)
db.OpenClawRunHistory.Add(history);
db.OutboxEvents.Add(CreateRunEvent("run.updated", run));
await db.SaveChangesAsync(cancellationToken);
}
private static OutboxEvent CreateRunEvent(
string type,
OpenClawRun run,
Guid? sourceRunId = null)
=> new()
{
Type = type,
AggregateType = "run",
AggregateId = run.Id.ToString(),
AggregateRevision = run.Revision,
PayloadJson = JsonSerializer.Serialize(new
{
status = run.Status,
taskId = run.TaskId,
projectId = run.ProjectId,
agentId = run.AgentId,
sourceRunId
})
};
}
+29
View File
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using System.Text.Json;
namespace Nexus.Api.Repositories;
@@ -14,6 +15,7 @@ public sealed class ProjectRepository(NexusDbContext db) : IProjectRepository
public async Task<Project> AddAsync(Project project, CancellationToken ct = default)
{
db.Projects.Add(project);
db.OutboxEvents.Add(CreateProjectEvent("project.created", project));
await db.SaveChangesAsync(ct);
return project;
}
@@ -21,15 +23,42 @@ public sealed class ProjectRepository(NexusDbContext db) : IProjectRepository
public async Task UpdateAsync(Project project, CancellationToken ct = default)
{
project.UpdatedAt = DateTimeOffset.UtcNow;
db.OutboxEvents.Add(CreateProjectEvent("project.updated", project));
await db.SaveChangesAsync(ct);
}
public async Task DeleteAsync(Project project, CancellationToken ct = default)
{
db.OutboxEvents.Add(CreateProjectEvent("project.deleted", project));
db.Projects.Remove(project);
await db.SaveChangesAsync(ct);
}
public Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default)
=> db.Tasks.AnyAsync(t => t.ProjectId == projectId, ct);
public Task<List<WorkTask>> GetTasksAsync(
Guid projectId,
CancellationToken ct = default)
=> db.Tasks
.AsNoTracking()
.Where(task => task.ProjectId == projectId)
.OrderBy(task => task.State == "Done" ? 1 : 0)
.ThenByDescending(task => task.UpdatedAt)
.ThenBy(task => task.Id)
.ToListAsync(ct);
private static OutboxEvent CreateProjectEvent(string type, Project project)
=> new()
{
Type = type,
AggregateType = "project",
AggregateId = project.Id.ToString(),
AggregateRevision = 0,
PayloadJson = JsonSerializer.Serialize(new
{
status = project.Status,
progress = project.Progress
})
};
}
+145
View File
@@ -1,5 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Models;
using System.Text.Json;
namespace Nexus.Api.Repositories;
@@ -23,6 +25,7 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
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;
}
@@ -47,10 +50,12 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
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)
@@ -59,6 +64,17 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
.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;
}
@@ -66,11 +82,13 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
{
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);
}
@@ -86,4 +104,131 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
.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
})
};
}