Compare commits

...

6 Commits

Author SHA1 Message Date
devops f271602f31 chore: bump version to 0.2.55 [skip ci] 2026-06-14 07:29:01 +00:00
reviewer 63319e1046 fix: stream deploy env into docker cli
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:27:56 +02:00
devops b730fa1518 chore: bump version to 0.2.54 [skip ci] 2026-06-14 07:21:34 +00:00
reviewer fadb5d75c4 Fix AgentService tests fixture path
CI - Build & Test / Backend (.NET) (push) Successful in 30s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:20:28 +02:00
reviewer 45a39d319f Fix operations CI and snapshots
CI - Build & Test / Backend (.NET) (push) Failing after 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:14:24 +02:00
reviewer 5ea7aa9611 fix(ops): mount temp env directory for compose
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-14 08:48:23 +02:00
8 changed files with 214 additions and 36 deletions
+2 -2
View File
@@ -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
+4 -2
View File
@@ -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"
+3 -3
View File
@@ -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"
+1 -1
View File
@@ -1 +1 @@
0.2.53 0.2.55
+52 -18
View File
@@ -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
+143
View File
@@ -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));
} }
+7 -10
View File
@@ -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 })
}; };
} }
} }