Compare commits

..

10 Commits

Author SHA1 Message Date
devops 36b32f0e88 chore: bump version to 0.2.56 [skip ci] 2026-06-14 07:50:18 +00:00
reviewer 8a556c25a0 Add local liveness health endpoint
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:49:25 +02:00
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
devops a6fabb90b0 chore: bump version to 0.2.53 [skip ci] 2026-06-14 06:46:55 +00:00
reviewer db62354c97 fix(ops): pass temp env via compose --env-file
CI - Build & Test / Backend (.NET) (push) Failing after 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 08:44:42 +02:00
10 changed files with 225 additions and 41 deletions
+2 -2
View File
@@ -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
+8 -6
View File
@@ -234,22 +234,24 @@ jobs:
docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v "${ENV_TMPFILE}:/workspace/nexus/.env:ro" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
-i \
docker:cli \
sh -c "
set -e
trap 'rm -f /tmp/nexus-deploy-env' EXIT
cat > /tmp/nexus-deploy-env
if [ -n '${SERVICE_ARG}' ]; then
echo '🚀 Deploying service: ${SERVICE_ARG}'
docker compose build ${BUILD_ARGS} ${SERVICE_ARG}
docker compose up -d --wait --force-recreate ${SERVICE_ARG}
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS} ${SERVICE_ARG}
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate ${SERVICE_ARG}
else
echo '🚀 Deploying all services'
docker compose build ${BUILD_ARGS}
docker compose up -d --wait --force-recreate
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS}
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate
fi
"
" < "${ENV_TMPFILE}"
echo "✅ Docker compose up completed"
+3 -3
View File
@@ -151,15 +151,15 @@ jobs:
docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v "${ENV_TMPFILE}:/workspace/nexus/.env:ro" \
-v "/tmp:/tmp-host:ro" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
docker:cli \
sh -c "
set -e
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
docker compose build --no-cache
docker compose up -d --wait --force-recreate
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
"
echo "✅ Rollback redeploy completed"
+1 -1
View File
@@ -1 +1 @@
0.2.52
0.2.56
+52 -18
View File
@@ -11,12 +11,8 @@ public class AgentServiceTests
[Fact]
public async Task GetAgentsAsync_ReturnsCorrectCount()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
})
.Build();
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
@@ -27,12 +23,8 @@ public class AgentServiceTests
[Fact]
public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
})
.Build();
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
@@ -44,18 +36,60 @@ public class AgentServiceTests
[Fact]
public async Task GetAgentAsync_Unknown_ReturnsNull()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
})
.Build();
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
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
+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();
}
+6
View File
@@ -7,6 +7,12 @@ namespace Nexus.Api.Controllers;
[ApiController]
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
{
[HttpGet("/health/live")]
public IResult Live()
{
return Results.Ok(new { status = "Healthy", timestamp = DateTimeOffset.UtcNow });
}
[HttpGet("/health")]
public async Task<IResult> Get(CancellationToken ct)
{
@@ -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<IResult> GetSnapshot(CancellationToken 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 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 })
};
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ services:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"]
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
interval: 30s
timeout: 10s
retries: 3