Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f271602f31 | |||
| 63319e1046 | |||
| b730fa1518 | |||
| fadb5d75c4 | |||
| 45a39d319f | |||
| 5ea7aa9611 |
@@ -27,10 +27,10 @@ jobs:
|
|||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore backend/Nexus.Api.csproj
|
run: dotnet restore backend-tests/Nexus.Api.Tests.csproj
|
||||||
|
|
||||||
- name: Build
|
- 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
|
- name: Test
|
||||||
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
|
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
|
||||||
|
|||||||
@@ -234,12 +234,14 @@ jobs:
|
|||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||||
-v "${ENV_TMPFILE}:/tmp/nexus-deploy-env:ro" \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-w /workspace/nexus \
|
-w /workspace/nexus \
|
||||||
|
-i \
|
||||||
docker:cli \
|
docker:cli \
|
||||||
sh -c "
|
sh -c "
|
||||||
set -e
|
set -e
|
||||||
|
trap 'rm -f /tmp/nexus-deploy-env' EXIT
|
||||||
|
cat > /tmp/nexus-deploy-env
|
||||||
if [ -n '${SERVICE_ARG}' ]; then
|
if [ -n '${SERVICE_ARG}' ]; then
|
||||||
echo '🚀 Deploying service: ${SERVICE_ARG}'
|
echo '🚀 Deploying service: ${SERVICE_ARG}'
|
||||||
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS} ${SERVICE_ARG}
|
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS} ${SERVICE_ARG}
|
||||||
@@ -249,7 +251,7 @@ jobs:
|
|||||||
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS}
|
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS}
|
||||||
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate
|
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate
|
||||||
fi
|
fi
|
||||||
"
|
" < "${ENV_TMPFILE}"
|
||||||
|
|
||||||
echo "✅ Docker compose up completed"
|
echo "✅ Docker compose up completed"
|
||||||
|
|
||||||
|
|||||||
@@ -151,15 +151,15 @@ jobs:
|
|||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||||
-v "${ENV_TMPFILE}:/tmp/nexus-deploy-env:ro" \
|
-v "/tmp:/tmp-host:ro" \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-w /workspace/nexus \
|
-w /workspace/nexus \
|
||||||
docker:cli \
|
docker:cli \
|
||||||
sh -c "
|
sh -c "
|
||||||
set -e
|
set -e
|
||||||
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
|
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
|
||||||
docker compose --env-file /tmp/nexus-deploy-env build --no-cache
|
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
|
||||||
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate
|
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
|
||||||
"
|
"
|
||||||
|
|
||||||
echo "✅ Rollback redeploy completed"
|
echo "✅ Rollback redeploy completed"
|
||||||
|
|||||||
@@ -11,12 +11,8 @@ public class AgentServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAgentsAsync_ReturnsCorrectCount()
|
public async Task GetAgentsAsync_ReturnsCorrectCount()
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var configPath = CreateAgentConfigFile();
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
var config = CreateConfiguration(configPath);
|
||||||
{
|
|
||||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
var runtime = new FakeRuntime();
|
var runtime = new FakeRuntime();
|
||||||
var service = new AgentService(config, runtime);
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
@@ -27,12 +23,8 @@ public class AgentServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
|
public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var configPath = CreateAgentConfigFile();
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
var config = CreateConfiguration(configPath);
|
||||||
{
|
|
||||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
var runtime = new FakeRuntime();
|
var runtime = new FakeRuntime();
|
||||||
var service = new AgentService(config, runtime);
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
@@ -44,18 +36,60 @@ public class AgentServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAgentAsync_Unknown_ReturnsNull()
|
public async Task GetAgentAsync_Unknown_ReturnsNull()
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var configPath = CreateAgentConfigFile();
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
var config = CreateConfiguration(configPath);
|
||||||
{
|
|
||||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
var runtime = new FakeRuntime();
|
var runtime = new FakeRuntime();
|
||||||
var service = new AgentService(config, runtime);
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
|
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
|
||||||
Assert.Null(agent);
|
Assert.Null(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IConfiguration CreateConfiguration(string configPath)
|
||||||
|
=> new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AgentConfigPath"] = configPath
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private static string CreateAgentConfigFile()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
||||||
|
File.WriteAllText(path,
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "/workspace/default",
|
||||||
|
"model": {
|
||||||
|
"primary": "deepseek/deepseek-v4-flash"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "iris",
|
||||||
|
"name": "iris"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "programmer",
|
||||||
|
"name": "programmer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reviewer",
|
||||||
|
"name": "reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "architekt",
|
||||||
|
"name": "architekt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class FakeRuntime : IAgentRuntime
|
public sealed class FakeRuntime : IAgentRuntime
|
||||||
|
|||||||
@@ -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<AuthorizeAttribute>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<T> RunAsync<T>(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<List<Project>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<Project>
|
||||||
|
{
|
||||||
|
new() { Name = "Alpha", Status = OperationalStatus.Online, Progress = 75 }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
public ValueTask<Project?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<Project> 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<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) : ITaskRepository
|
||||||
|
{
|
||||||
|
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<WorkTask>
|
||||||
|
{
|
||||||
|
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<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<WorkTask> 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<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<int> CountByStateAsync(string state, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard) : IActivityRepository
|
||||||
|
{
|
||||||
|
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<ActivityEvent>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SnapshotRuntimeStub : IAgentRuntime
|
||||||
|
{
|
||||||
|
public string Name => "stub";
|
||||||
|
|
||||||
|
public Task<AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(new AgentRuntimeStatus("OpenClaw", OperationalStatus.Online, TimeSpan.FromMilliseconds(5), "ok"));
|
||||||
|
|
||||||
|
public Task<AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SnapshotAgentServiceStub : IAgentService
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>(
|
||||||
|
[
|
||||||
|
new AgentInfo("iris", "Iris", "Orchestrator", "model", OperationalStatus.Online, DateTimeOffset.UtcNow, "/workspace", "ops")
|
||||||
|
]);
|
||||||
|
|
||||||
|
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ namespace Nexus.Api.Controllers;
|
|||||||
public class OperationsController(IOperationsService operationsService) : ControllerBase
|
public class OperationsController(IOperationsService operationsService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("snapshot")]
|
[HttpGet("snapshot")]
|
||||||
|
[Authorize]
|
||||||
public async Task<IResult> GetSnapshot(CancellationToken ct)
|
public async Task<IResult> GetSnapshot(CancellationToken ct)
|
||||||
=> Results.Ok(await operationsService.GetSnapshotAsync(ct));
|
=> Results.Ok(await operationsService.GetSnapshotAsync(ct));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,16 +15,13 @@ public sealed class OperationsService(
|
|||||||
{
|
{
|
||||||
var runtimeTask = runtime.GetStatusAsync(ct);
|
var runtimeTask = runtime.GetStatusAsync(ct);
|
||||||
var agentsTask = agentService.GetAgentsAsync(ct);
|
var agentsTask = agentService.GetAgentsAsync(ct);
|
||||||
var projectsTask = projectRepo.GetAllAsync(ct);
|
// Repository calls share the scoped EF Core DbContext and must stay serialized.
|
||||||
var tasksTask = taskRepo.GetAllAsync(ct);
|
var projects = await projectRepo.GetAllAsync(ct);
|
||||||
var activityTask = activityRepo.GetRecentAsync(20, ct);
|
var tasks = await taskRepo.GetAllAsync(ct);
|
||||||
await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask);
|
var activity = await activityRepo.GetRecentAsync(20, ct);
|
||||||
|
var agents = await agentsTask;
|
||||||
var tasks = tasksTask.Result;
|
|
||||||
var projects = projectsTask.Result;
|
|
||||||
var agents = agentsTask.Result;
|
|
||||||
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
|
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
|
||||||
var runtimeStatus = runtimeTask.Result;
|
var runtimeStatus = await runtimeTask;
|
||||||
|
|
||||||
var lastIncident = tasks
|
var lastIncident = tasks
|
||||||
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
.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 }),
|
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 }),
|
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 }),
|
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 })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user