feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user