diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 6839971..cbad84b 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -27,10 +27,10 @@ jobs: dotnet-version: '10.0.x' - name: Restore - run: dotnet restore backend/Nexus.Api.csproj + run: dotnet restore backend-tests/Nexus.Api.Tests.csproj - name: Build - run: dotnet build backend/Nexus.Api.csproj --no-restore --configuration Release + run: dotnet build backend-tests/Nexus.Api.Tests.csproj --no-restore --configuration Release - name: Test run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal diff --git a/backend-tests/OperationsSnapshotTests.cs b/backend-tests/OperationsSnapshotTests.cs new file mode 100644 index 0000000..f78982f --- /dev/null +++ b/backend-tests/OperationsSnapshotTests.cs @@ -0,0 +1,143 @@ +using System.Reflection; +using Microsoft.AspNetCore.Authorization; +using Nexus.Api.Controllers; +using Nexus.Api.Data; +using Nexus.Api.Integrations; +using Nexus.Api.Repositories; +using Nexus.Api.Services; +using Xunit; + +namespace Nexus.Api.Tests; + +public class OperationsSnapshotTests +{ + [Fact] + public void GetSnapshot_RequiresAuthorization() + { + var method = typeof(OperationsController).GetMethod(nameof(OperationsController.GetSnapshot), BindingFlags.Instance | BindingFlags.Public); + + Assert.NotNull(method); + Assert.NotNull(method!.GetCustomAttribute()); + } + + [Fact] + public async Task GetSnapshotAsync_DoesNotOverlapRepositoryReads() + { + var guard = new RepositoryConcurrencyGuard(); + var runtime = new SnapshotRuntimeStub(); + var agentService = new SnapshotAgentServiceStub(); + var projectRepo = new GuardedProjectRepository(guard); + var taskRepo = new GuardedTaskRepository(guard); + var activityRepo = new GuardedActivityRepository(guard); + var service = new OperationsService(runtime, agentService, projectRepo, taskRepo, activityRepo); + + await service.GetSnapshotAsync(CancellationToken.None); + + Assert.Equal(1, guard.MaxConcurrentCalls); + } +} + +internal sealed class RepositoryConcurrencyGuard +{ + private readonly Lock sync = new(); + private int currentCalls; + + public int MaxConcurrentCalls { get; private set; } + + public async Task RunAsync(T value, CancellationToken ct) + { + lock (sync) + { + currentCalls++; + MaxConcurrentCalls = Math.Max(MaxConcurrentCalls, currentCalls); + } + + try + { + await Task.Delay(25, ct); + return value; + } + finally + { + lock (sync) + { + currentCalls--; + } + } + } +} + +internal sealed class GuardedProjectRepository(RepositoryConcurrencyGuard guard) : IProjectRepository +{ + public Task> GetAllAsync(CancellationToken ct = default) + => guard.RunAsync(new List + { + new() { Name = "Alpha", Status = OperationalStatus.Online, Progress = 75 } + }, ct); + + public ValueTask GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); + public Task AddAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UpdateAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DeleteAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException(); + public Task HasTasksAsync(Guid projectId, CancellationToken ct = default) => throw new NotSupportedException(); +} + +internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) : ITaskRepository +{ + public Task> GetAllAsync(CancellationToken ct = default) + => guard.RunAsync(new List + { + new() { Title = "Blocked task", State = TaskStateHelper.ToStateString(TaskState.Blocked), UpdatedAt = DateTimeOffset.UtcNow }, + new() { Title = "Done task", State = TaskStateHelper.ToStateString(TaskState.Done), UpdatedAt = DateTimeOffset.UtcNow } + }, ct); + + public ValueTask GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); + public Task> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException(); + public Task AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException(); + public Task CountAsync(CancellationToken ct = default) => throw new NotSupportedException(); + public Task CountByStateAsync(string state, CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetLastBlockedAsync(CancellationToken ct = default) => throw new NotSupportedException(); +} + +internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard) : IActivityRepository +{ + public Task> GetRecentAsync(int take, CancellationToken ct = default) + => guard.RunAsync(new List + { + new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow } + }, ct); + + public Task<(List Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default) + => throw new NotSupportedException(); + + public Task> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) + => throw new NotSupportedException(); + + public Task AddAsync(ActivityEvent activity, CancellationToken ct = default) + => throw new NotSupportedException(); +} + +internal sealed class SnapshotRuntimeStub : IAgentRuntime +{ + public string Name => "stub"; + + public Task GetStatusAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new AgentRuntimeStatus("OpenClaw", OperationalStatus.Online, TimeSpan.FromMilliseconds(5), "ok")); + + public Task ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); +} + +internal sealed class SnapshotAgentServiceStub : IAgentService +{ + public Task> GetAgentsAsync(CancellationToken cancellationToken) + => Task.FromResult>( + [ + new AgentInfo("iris", "Iris", "Orchestrator", "model", OperationalStatus.Online, DateTimeOffset.UtcNow, "/workspace", "ops") + ]); + + public Task GetAgentAsync(string id, CancellationToken cancellationToken) + => throw new NotSupportedException(); +} diff --git a/backend/Controllers/OperationsController.cs b/backend/Controllers/OperationsController.cs index 543c838..d15ff72 100644 --- a/backend/Controllers/OperationsController.cs +++ b/backend/Controllers/OperationsController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Nexus.Api.Services; @@ -8,6 +9,7 @@ namespace Nexus.Api.Controllers; public class OperationsController(IOperationsService operationsService) : ControllerBase { [HttpGet("snapshot")] + [Authorize] public async Task GetSnapshot(CancellationToken ct) => Results.Ok(await operationsService.GetSnapshotAsync(ct)); } diff --git a/backend/Services/OperationsService.cs b/backend/Services/OperationsService.cs index 04c1649..baf4eb4 100644 --- a/backend/Services/OperationsService.cs +++ b/backend/Services/OperationsService.cs @@ -15,16 +15,13 @@ public sealed class OperationsService( { var runtimeTask = runtime.GetStatusAsync(ct); var agentsTask = agentService.GetAgentsAsync(ct); - var projectsTask = projectRepo.GetAllAsync(ct); - var tasksTask = taskRepo.GetAllAsync(ct); - var activityTask = activityRepo.GetRecentAsync(20, ct); - await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask); - - var tasks = tasksTask.Result; - var projects = projectsTask.Result; - var agents = agentsTask.Result; + // Repository calls share the scoped EF Core DbContext and must stay serialized. + var projects = await projectRepo.GetAllAsync(ct); + var tasks = await taskRepo.GetAllAsync(ct); + var activity = await activityRepo.GetRecentAsync(20, ct); + var agents = await agentsTask; var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done)); - var runtimeStatus = runtimeTask.Result; + var runtimeStatus = await runtimeTask; var lastIncident = tasks .Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked)) @@ -56,7 +53,7 @@ public sealed class OperationsService( agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }), projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }), tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }), - activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }) + activity = activity.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }) }; } }