refactor: SOLID architecture — backend service layer + frontend V2 components
## Backend — Service Layer & Repository Refactoring ### Neue Services (21 neue Dateien) **Interfaces & Implementierungen:** - `IOpenClawGatewayClient` — Interface für OpenClawGatewayClient (DIP-Fix: DashboardController hing an konkreter Klasse) - `IAgentConfigService` / `AgentConfigService` — Agent-Config-File-I/O aus AgentsController extrahiert - `IProjectService` / `ProjectService` — Projekt-CRUD + Activity-Logging (SRP) - `ITaskService` / `TaskService` — Task-State-Machine, Approve/Reject, Dashboard-Operationen (eliminiert Duplikation zwischen TasksController und DashboardController) - `IDashboardService` / `DashboardService` — Queue-Aggregation, Priority-Normalisierung, Gateway-Delegation - `IOperationsService` / `OperationsService` — Metriken-Berechnung aus OperationsController - `ITeamService` / `TeamService` — IDENTITY.md-Lesen aus TeamController - `IMemoryService` / `MemoryService` — File-I/O aus MemoryController - `IIncidentService` / `IncidentService` — File-Parsing (Regex-Source-Generatoren) aus IncidentsController - `IDocService` / `DocService` — Directory-Scan aus DocsController - `ICalendarService` / `CalendarService` — Gateway-HTTP-Calls + Fallback-Daten aus CalendarController ### Repository-Fixes **IUserRepository / UserRepository:** - `SaveChangesAsync` entfernt (leaky abstraction — Caller sollten nie SaveChanges steuern) - `RevokeTokenAsync(tokenHash)` — atomares Token-Revoke inkl. SaveChanges - `RevokeFamilyAsync(familyId)` — Batch-Revoke einer Token-Familie inkl. SaveChanges - `RemoveExpiredTokensAsync` speichert jetzt selbst (war vorher dependent auf nachfolgenden Save) ### AuthService-Fixes - `GetUserAsync`: unnötiges `Task.Run` entfernt → direkt `_users.GetByIdAsync().AsTask()` - `RevokeAsync`: delegiert jetzt an `IUserRepository.RevokeTokenAsync` - `RefreshAsync`: Token-Reuse-Detection delegiert an `IUserRepository.RevokeFamilyAsync` ### Bug-Fix - `OpenClawGatewayClient.ReadAgentGoalAsync`: pre-existing `CS1656` behoben (`reader` war `using`-Variable und wurde neu zugewiesen — in `reader2` umbenannt) ### Controller (16 Stück — alle slim) Alle Controller reduziert auf: Input validieren → Service aufrufen → HTTP-Result zurückgeben. Kein Business-Logic, kein File-I/O, keine direkte Repository-Nutzung (außer AgentsController für Activity-Log). **Program.cs — neue Registrierungen:** - `AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>` (war vorher konkrete Klasse) - Scoped: IDashboardService, IProjectService, ITaskService, IOperationsService, ITeamService, ICalendarService - Singleton: IAgentConfigService, IMemoryService, IIncidentService, IDocService --- ## Frontend — Dashboard V2 Components **AgentDetailModal.vue, IrisChat.vue, TaskStrip.vue:** - V2 Design-System: Dark Space Theme, Glass-Panels, Gradient-Akzente - Stores (agents, chat, tasks) nutzen Service + Mapper-Pattern - NexusLayout, FlowBoard, Topbar — Layoutfixes für fullHeight-Route-Meta Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Helpers;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
@@ -15,6 +13,7 @@ public class AgentsController(
|
||||
IAgentService agentService,
|
||||
IAgentRuntime runtime,
|
||||
IActivityRepository activityRepo,
|
||||
IAgentConfigService agentConfigService,
|
||||
ILogger<AgentsController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
@@ -22,8 +21,7 @@ public class AgentsController(
|
||||
{
|
||||
var agents = await agentService.GetAgentsAsync(ct);
|
||||
return Results.Ok(agents.Select(a => new AgentListResponse(
|
||||
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description
|
||||
)));
|
||||
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description)));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@@ -34,8 +32,7 @@ public class AgentsController(
|
||||
return Results.Ok(new AgentDetailResponse(
|
||||
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(),
|
||||
agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description,
|
||||
agent.SubAgents, agent.IdentityName
|
||||
));
|
||||
agent.SubAgents, agent.IdentityName));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/activity")]
|
||||
@@ -58,9 +55,7 @@ public class AgentsController(
|
||||
try
|
||||
{
|
||||
var result = await runtime.ChatAsync(message, conversationId, id, ct);
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
|
||||
|
||||
await activityRepo.AddAsync(new Data.ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
|
||||
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -73,79 +68,33 @@ public class AgentsController(
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Agent Config Editor ==========
|
||||
// ── Config Editor ──
|
||||
|
||||
[HttpGet("{id}/config")]
|
||||
public IResult GetConfig(string id)
|
||||
{
|
||||
var workspacePath = $"/mnt/workspace-{id}";
|
||||
if (!Directory.Exists(workspacePath))
|
||||
return Results.Ok(Array.Empty<object>());
|
||||
|
||||
var allowedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
|
||||
};
|
||||
|
||||
var files = Directory.GetFiles(workspacePath, "*.md")
|
||||
.Select(f => new FileInfo(f))
|
||||
.Where(f => allowedFiles.Contains(f.Name))
|
||||
.OrderBy(f => f.Name)
|
||||
.Select(f => new
|
||||
{
|
||||
fileName = f.Name,
|
||||
size = f.Length,
|
||||
modifiedAt = f.LastWriteTimeUtc
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(files);
|
||||
}
|
||||
=> Results.Ok(agentConfigService.GetConfigFiles(id));
|
||||
|
||||
[HttpGet("{id}/config/{fileName}")]
|
||||
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
|
||||
{
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
|
||||
|
||||
var workspacePath = $"/mnt/workspace-{id}";
|
||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !System.IO.File.Exists(safePath))
|
||||
return Results.NotFound();
|
||||
|
||||
var content = await System.IO.File.ReadAllTextAsync(safePath!, ct);
|
||||
var fi = new FileInfo(safePath!);
|
||||
return Results.Ok(new { fileName, content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
||||
var file = await agentConfigService.GetConfigFileAsync(id, fileName, ct);
|
||||
return file is null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(new { file.FileName, file.Content, file.Size, file.ModifiedAt });
|
||||
}
|
||||
|
||||
[HttpPut("{id}/config/{fileName}")]
|
||||
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
|
||||
|
||||
if (request.Content is null)
|
||||
return Results.BadRequest(new { error = "Content is required." });
|
||||
|
||||
if (request.Content.Length > 500 * 1024)
|
||||
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
|
||||
|
||||
var workspacePath = $"/mnt/workspace-{id}";
|
||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
|
||||
return Results.NotFound();
|
||||
|
||||
var tempPath = safePath + ".tmp";
|
||||
try
|
||||
{
|
||||
await System.IO.File.WriteAllTextAsync(tempPath, request.Content, ct);
|
||||
System.IO.File.Move(tempPath, safePath, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath);
|
||||
throw;
|
||||
}
|
||||
|
||||
var fi = new FileInfo(safePath);
|
||||
return Results.Ok(new { fileName, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
||||
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
||||
return result is null
|
||||
? Results.BadRequest(new { error = "Invalid filename or path." })
|
||||
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +1,17 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/calendar")]
|
||||
public class CalendarController(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<CalendarController> logger) : ControllerBase
|
||||
public class CalendarController(ICalendarService calendarService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetAll(CancellationToken ct)
|
||||
{
|
||||
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = httpClientFactory.CreateClient("gateway");
|
||||
if (!string.IsNullOrWhiteSpace(gatewayToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
|
||||
|
||||
var response = await httpClient.GetAsync("/api/cron", ct);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
|
||||
return Results.Ok(data ?? new List<CronJobEntry>());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data.");
|
||||
}
|
||||
|
||||
var fallbackJobs = new List<object>
|
||||
{
|
||||
new { id = "health-check", name = "Health Check", schedule = "*/5 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-3).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(2).ToString("O"), status = "completed" },
|
||||
new { id = "memory-sync", name = "Memory Sync", schedule = "0 */6 * * *", lastRun = DateTimeOffset.UtcNow.AddHours(-2).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddHours(4).ToString("O"), status = "completed" },
|
||||
new { id = "task-cleanup", name = "Task Cleanup", schedule = "0 3 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(3).ToString("O"), status = "completed" },
|
||||
new { id = "backup", name = "Database Backup", schedule = "0 4 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).AddHours(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(4).ToString("O"), status = "completed" },
|
||||
new { id = "model-routing-refresh", name = "Model Routing Refresh", schedule = "*/30 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-12).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(18).ToString("O"), status = "running" },
|
||||
};
|
||||
return Results.Ok(fallbackJobs);
|
||||
}
|
||||
=> Results.Ok(await calendarService.GetCronJobsAsync(ct));
|
||||
|
||||
[HttpGet("upcoming")]
|
||||
public async Task<IResult> GetUpcoming(CancellationToken ct)
|
||||
{
|
||||
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = httpClientFactory.CreateClient("gateway");
|
||||
if (!string.IsNullOrWhiteSpace(gatewayToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
|
||||
|
||||
var response = await httpClient.GetAsync("/api/cron/upcoming", ct);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
|
||||
return Results.Ok(data ?? new List<UpcomingCronEntry>());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data.");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var fallback = new List<object>
|
||||
{
|
||||
new { id = "health-check", name = "Health Check", nextRun = now.AddMinutes(2).ToString("O"), schedule = "*/5 * * * *" },
|
||||
new { id = "model-routing-refresh", name = "Model Routing Refresh", nextRun = now.AddMinutes(18).ToString("O"), schedule = "*/30 * * * *" },
|
||||
new { id = "memory-sync", name = "Memory Sync", nextRun = now.AddHours(4).ToString("O"), schedule = "0 */6 * * *" },
|
||||
new { id = "task-cleanup", name = "Task Cleanup", nextRun = now.AddDays(1).AddHours(3).ToString("O"), schedule = "0 3 * * *" },
|
||||
new { id = "backup", name = "Database Backup", nextRun = now.AddDays(1).AddHours(4).ToString("O"), schedule = "0 4 * * *" },
|
||||
};
|
||||
return Results.Ok(fallback);
|
||||
}
|
||||
=> Results.Ok(await calendarService.GetUpcomingCronJobsAsync(ct));
|
||||
}
|
||||
|
||||
@@ -1,403 +1,113 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/dashboard")]
|
||||
public class DashboardController(
|
||||
OpenClawGatewayClient gateway,
|
||||
ITaskRepository taskRepo,
|
||||
IActivityRepository activityRepo,
|
||||
ILogger<DashboardController> logger)
|
||||
: ControllerBase
|
||||
public class DashboardController(IDashboardService dashboardService, ITaskService taskService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gateway health + session_status + subagents count.
|
||||
/// Returns HTTP 200 even when gateway is down (gatewayOk: false).
|
||||
/// </summary>
|
||||
[HttpGet("status")]
|
||||
public async Task<DashboardStatus> GetStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetStatusAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard status check failed");
|
||||
return new DashboardStatus(false, "Offline", 0, 0);
|
||||
}
|
||||
}
|
||||
=> await dashboardService.GetStatusAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Returns all agents with their current status.
|
||||
/// Combines sessions_list + sub_agents_list.
|
||||
/// </summary>
|
||||
[HttpGet("agents")]
|
||||
public async Task<List<DashboardAgentInfo>> GetAgents()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetAgentsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard agents fetch failed");
|
||||
return new List<DashboardAgentInfo>();
|
||||
}
|
||||
}
|
||||
=> await dashboardService.GetAgentsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the latest assistant messages aggregated from ALL agent sessions.
|
||||
/// Events are sorted by timestamp descending (newest first).
|
||||
/// Supports optional agent filter via ?agent= query parameter.
|
||||
/// Falls back to Iris-only feed if multi-agent feed fails.
|
||||
/// </summary>
|
||||
[HttpGet("operations")]
|
||||
public async Task<List<FeedEntry>> GetOperations(
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string? agent = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
|
||||
=> await dashboardService.GetOperationsAsync(limit, agent);
|
||||
|
||||
// Optional agent filter
|
||||
if (!string.IsNullOrWhiteSpace(agent))
|
||||
{
|
||||
entries = entries
|
||||
.Where(e => string.Equals(e.AgentId, agent, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(e.Agent, agent, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard operations fetch failed");
|
||||
return new List<FeedEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send a chat message to the Iris session.
|
||||
/// </summary>
|
||||
[HttpPost("chat/send")]
|
||||
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Message))
|
||||
return new ChatResponse(false, null, "Message is required");
|
||||
|
||||
try
|
||||
{
|
||||
var agentId = string.IsNullOrWhiteSpace(request.AgentId)
|
||||
? "iris"
|
||||
: request.AgentId.Trim();
|
||||
|
||||
return await gateway.SendChatMessageAsync(agentId, request.Message.Trim());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard chat send failed");
|
||||
return new ChatResponse(false, null, "Gateway nicht erreichbar");
|
||||
}
|
||||
var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim();
|
||||
return await dashboardService.SendChatAsync(agentId, request.Message.Trim());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns chat messages (user + assistant only, not tool messages).
|
||||
/// </summary>
|
||||
[HttpGet("chat/messages")]
|
||||
public async Task<List<MessageEntry>> GetMessages(
|
||||
[FromQuery] string? sessionKey,
|
||||
[FromQuery] int limit = 50,
|
||||
[FromQuery] int offset = 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim();
|
||||
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
|
||||
=> await dashboardService.GetMessagesAsync(sessionKey, limit, offset);
|
||||
|
||||
return messages
|
||||
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard messages fetch failed");
|
||||
return new List<MessageEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns aggregated queue: cron jobs + open tasks (merged, sorted by priority).
|
||||
/// </summary>
|
||||
[HttpGet("queue")]
|
||||
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Fetch cron jobs and open tasks concurrently
|
||||
var cronTask = gateway.GetQueueAsync();
|
||||
var tasksTask = taskRepo.GetAllAsync(ct);
|
||||
=> await dashboardService.GetQueueAsync(ct);
|
||||
|
||||
await Task.WhenAll(cronTask, tasksTask);
|
||||
|
||||
var cronJobs = cronTask.Result;
|
||||
var openTasks = tasksTask.Result
|
||||
.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
var merged = new List<QueueItem>();
|
||||
|
||||
// Map cron jobs (already in QueueItem format from gateway)
|
||||
merged.AddRange(cronJobs);
|
||||
|
||||
// Map open tasks to QueueItems
|
||||
foreach (var t in openTasks)
|
||||
{
|
||||
var priority = NormalizePriority(t.Priority);
|
||||
merged.Add(new QueueItem(
|
||||
"task-" + t.Id.ToString(),
|
||||
t.Title,
|
||||
t.State,
|
||||
priority,
|
||||
"task",
|
||||
"--"
|
||||
));
|
||||
}
|
||||
|
||||
// Sort: high priority first, then medium, then low
|
||||
var priorityOrder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["high"] = 0,
|
||||
["medium"] = 1,
|
||||
["low"] = 2
|
||||
};
|
||||
|
||||
return merged.OrderBy(q => priorityOrder.GetValueOrDefault(q.Priority, 99)).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard queue fetch failed");
|
||||
return new List<QueueItem>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizePriority(string priority)
|
||||
{
|
||||
return priority.ToLowerInvariant() switch
|
||||
{
|
||||
"high" or "critical" or "urgent" => "high",
|
||||
"low" or "minor" => "low",
|
||||
_ => "medium"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a queue item: cron jobs are deleted via gateway, tasks are set to Done.
|
||||
/// </summary>
|
||||
[HttpDelete("queue/{id}")]
|
||||
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
var result = await dashboardService.DeleteQueueItemAsync(id, source, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var ok = await gateway.DeleteCronJobAsync(id);
|
||||
if (!ok)
|
||||
return StatusCode(502, new { error = "Gateway could not delete cron job" });
|
||||
return NoContent();
|
||||
}
|
||||
else if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Extract the actual GUID from the prefixed id ("task-{guid}")
|
||||
if (!id.StartsWith("task-"))
|
||||
return BadRequest(new { error = "Invalid task id format" });
|
||||
|
||||
var guidStr = id["task-".Length..];
|
||||
if (!Guid.TryParse(guidStr, out var guid))
|
||||
return BadRequest(new { error = "Invalid task id" });
|
||||
|
||||
var task = await taskRepo.GetByIdAsync(guid, ct);
|
||||
if (task is null)
|
||||
return NotFound(new { error = "Task not found" });
|
||||
|
||||
// Set task status to Done instead of deleting
|
||||
task.State = "Done";
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "task",
|
||||
Message = $"Task \"{task.Title}\" completed via queue"
|
||||
}, ct);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// Default: try cron
|
||||
var deleted = await gateway.DeleteCronJobAsync(id);
|
||||
if (!deleted)
|
||||
return NotFound(new { error = "Queue item not found" });
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Delete queue item failed for {Id}", id);
|
||||
return StatusCode(500, new { error = "Internal error" });
|
||||
}
|
||||
QueueDeleteOutcome.Deleted => NoContent(),
|
||||
QueueDeleteOutcome.NotFound => NotFound(new { error = "Queue item not found" }),
|
||||
QueueDeleteOutcome.GatewayError => StatusCode(502, new { error = "Gateway could not delete cron job" }),
|
||||
QueueDeleteOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
|
||||
QueueDeleteOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
|
||||
_ => StatusCode(500, new { error = "Internal error" })
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes the priority of a queue item (only for tasks; cron jobs are ignored).
|
||||
/// Cycles: high → medium → low → high.
|
||||
/// </summary>
|
||||
[HttpPut("queue/{id}/priority")]
|
||||
public async Task<ActionResult> ChangeQueuePriority(string id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
var result = await dashboardService.CycleQueuePriorityAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
if (!id.StartsWith("task-"))
|
||||
return Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" });
|
||||
|
||||
var guidStr = id["task-".Length..];
|
||||
if (!Guid.TryParse(guidStr, out var guid))
|
||||
return BadRequest(new { error = "Invalid task id" });
|
||||
|
||||
var task = await taskRepo.GetByIdAsync(guid, ct);
|
||||
if (task is null)
|
||||
return NotFound(new { error = "Task not found" });
|
||||
|
||||
// Cycle priority: high → medium → low → high
|
||||
task.Priority = task.Priority.ToLowerInvariant() switch
|
||||
{
|
||||
"high" => "Medium",
|
||||
"medium" => "Low",
|
||||
"low" => "High",
|
||||
_ => "Medium"
|
||||
};
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "task",
|
||||
Message = $"Task \"{task.Title}\" priority → {task.Priority}"
|
||||
}, ct);
|
||||
|
||||
return Ok(new { status = "ok", priority = task.Priority });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Change queue priority failed for {Id}", id);
|
||||
return StatusCode(500, new { error = "Internal error" });
|
||||
}
|
||||
QueuePriorityOutcome.Ignored => Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" }),
|
||||
QueuePriorityOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
|
||||
QueuePriorityOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
|
||||
_ => Ok(new { status = "ok", priority = result.NewPriority })
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current model and provider for a specific agent session.
|
||||
/// Calls session_status with the agent's session key.
|
||||
/// </summary>
|
||||
[HttpGet("agents/{id}/model")]
|
||||
public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = await gateway.GetAgentModelAsync(id);
|
||||
if (info is null)
|
||||
return NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" });
|
||||
return Ok(info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", id);
|
||||
return StatusCode(500, new { error = "Internal error" });
|
||||
}
|
||||
var info = await dashboardService.GetAgentModelAsync(id);
|
||||
return info is null
|
||||
? NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" })
|
||||
: Ok(info);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the model for a specific agent session.
|
||||
/// Calls session_status with model parameter.
|
||||
/// </summary>
|
||||
[HttpPut("agents/{id}/model")]
|
||||
public async Task<ActionResult> SetAgentModel(string id, [FromBody] SetModelRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Model))
|
||||
return BadRequest(new { error = "Model is required" });
|
||||
|
||||
try
|
||||
{
|
||||
var ok = await gateway.SetAgentModelAsync(id, request.Model);
|
||||
if (!ok)
|
||||
return StatusCode(502, new { error = "Gateway did not accept the change" });
|
||||
return Ok(new { status = "ok", model = request.Model });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", id);
|
||||
return StatusCode(500, new { error = "Internal error" });
|
||||
}
|
||||
var ok = await dashboardService.SetAgentModelAsync(id, request.Model);
|
||||
return ok ? Ok(new { status = "ok", model = request.Model }) : StatusCode(502, new { error = "Gateway did not accept the change" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the most recent activity entries (assistant messages) for a specific agent.
|
||||
/// </summary>
|
||||
[HttpGet("agents/{id}/activity")]
|
||||
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetAgentActivityAsync(id, Math.Clamp(limit, 1, 20));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", id);
|
||||
return new List<AgentActivityEntry>();
|
||||
}
|
||||
}
|
||||
=> await dashboardService.GetAgentActivityAsync(id, limit);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the list of available models that can be assigned to agents.
|
||||
/// Reads from OpenClaw config dynamically, falls back to hardcoded list.
|
||||
/// </summary>
|
||||
[HttpGet("models")]
|
||||
public ActionResult<List<ModelOption>> GetAvailableModels()
|
||||
{
|
||||
var models = gateway.GetAvailableModels();
|
||||
return Ok(models);
|
||||
}
|
||||
=> Ok(dashboardService.GetAvailableModels());
|
||||
|
||||
// ========== Task Endpoints ==========
|
||||
// ── Task Endpoints ──
|
||||
|
||||
/// <summary>
|
||||
/// Returns all non-done tasks (status != 'Done'), ordered by creation date descending.
|
||||
/// </summary>
|
||||
[HttpGet("tasks")]
|
||||
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tasks = await taskRepo.GetAllAsync(ct);
|
||||
return tasks
|
||||
.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.Select(MapToDto)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard tasks fetch failed");
|
||||
return new List<DashboardTaskDto>();
|
||||
}
|
||||
var tasks = await taskService.GetOpenAsync(ct);
|
||||
return tasks.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new task and logs an activity event.
|
||||
/// </summary>
|
||||
[HttpPost("tasks")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> CreateTask(
|
||||
[FromBody] CreateDashboardTaskRequest request, CancellationToken ct)
|
||||
@@ -405,121 +115,49 @@ public class DashboardController(
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
return BadRequest(new { error = "Title is required." });
|
||||
|
||||
var task = new WorkTask
|
||||
{
|
||||
Title = request.Title.Trim(),
|
||||
Detail = request.Detail?.Trim(),
|
||||
Source = string.IsNullOrWhiteSpace(request.Source) ? "bao" : request.Source.Trim(),
|
||||
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
|
||||
AssignedTo = request.AssignedTo?.Trim(),
|
||||
};
|
||||
|
||||
await taskRepo.AddAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "task",
|
||||
Message = $"Task \"{task.Title}\" created ({task.Source})"
|
||||
}, ct);
|
||||
|
||||
var task = await taskService.CreateDashboardTaskAsync(
|
||||
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
|
||||
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing task (title, detail, source, priority, assignedTo).
|
||||
/// </summary>
|
||||
[HttpPut("tasks/{id:guid}")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
|
||||
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null)
|
||||
return NotFound(new { error = "Task not found." });
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||
task.Title = request.Title.Trim();
|
||||
if (request.Detail is not null)
|
||||
task.Detail = string.IsNullOrWhiteSpace(request.Detail) ? null : request.Detail.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Source))
|
||||
task.Source = request.Source.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Priority))
|
||||
task.Priority = request.Priority.Trim();
|
||||
if (request.AssignedTo is not null)
|
||||
task.AssignedTo = string.IsNullOrWhiteSpace(request.AssignedTo) ? null : request.AssignedTo.Trim();
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
var result = await taskService.UpdateDashboardTaskAsync(
|
||||
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
Type = "task",
|
||||
Message = $"Task \"{task.Title}\" updated"
|
||||
}, ct);
|
||||
|
||||
return Ok(MapToDto(task));
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
_ => Ok(MapToDto(result.Task!))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a task (only if status is 'Done' or 'Backlog').
|
||||
/// </summary>
|
||||
[HttpDelete("tasks/{id:guid}")]
|
||||
public async Task<ActionResult> DeleteTask(Guid id, CancellationToken ct)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null)
|
||||
return NotFound(new { error = "Task not found." });
|
||||
|
||||
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
||||
return StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." });
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
var result = await taskService.DeleteAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
Type = "task",
|
||||
Message = $"Task \"{task.Title}\" deleted"
|
||||
}, ct);
|
||||
await taskRepo.DeleteAsync(task, ct);
|
||||
|
||||
return NoContent();
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
TaskOperationOutcome.InvalidState => StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }),
|
||||
_ => NoContent()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes the status of a task.
|
||||
/// </summary>
|
||||
[HttpPatch("tasks/{id:guid}/status")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
|
||||
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!TaskStateHelper.IsValidState(request.Status))
|
||||
return BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" });
|
||||
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null)
|
||||
return NotFound(new { error = "Task not found." });
|
||||
|
||||
var canonicalState = TaskStateHelper.AllStates.First(s =>
|
||||
s.Equals(request.Status, StringComparison.OrdinalIgnoreCase));
|
||||
task.State = canonicalState;
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
Type = "task",
|
||||
Message = $"Task \"{task.Title}\" → {canonicalState}"
|
||||
}, ct);
|
||||
|
||||
return Ok(MapToDto(task));
|
||||
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
_ => Ok(MapToDto(result.Task!))
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||
t.Id,
|
||||
t.Title,
|
||||
t.Detail,
|
||||
t.Source,
|
||||
t.State,
|
||||
t.Priority,
|
||||
t.AssignedTo,
|
||||
t.CreatedAt,
|
||||
t.UpdatedAt
|
||||
);
|
||||
|
||||
|
||||
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,15 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Helpers;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/docs")]
|
||||
public class DocsController : ControllerBase
|
||||
public class DocsController(IDocService docService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IResult GetAll()
|
||||
{
|
||||
var workspaceRoot = "/mnt/workspace-iris";
|
||||
var results = new List<object>();
|
||||
|
||||
void ScanDir(string dir, string category)
|
||||
{
|
||||
if (!Directory.Exists(dir)) return;
|
||||
foreach (var file in Directory.GetFiles(dir, "*.*"))
|
||||
{
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
if (ext is not (".md" or ".json" or ".txt" or ".yaml" or ".yml" or ".html" or ".css"))
|
||||
continue;
|
||||
var fi = new FileInfo(file);
|
||||
results.Add(new
|
||||
{
|
||||
name = fi.Name,
|
||||
path = file.Replace(workspaceRoot, "").TrimStart('/'),
|
||||
category,
|
||||
type = ext.Replace(".", ""),
|
||||
size = fi.Length,
|
||||
modifiedAt = fi.LastWriteTimeUtc
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ScanDir("/mnt/workspace-iris/nexus-phases", "phases");
|
||||
ScanDir("/mnt/workspace-iris/skills", "skills");
|
||||
ScanDir("/mnt/workspace-iris", "workspace");
|
||||
ScanDir("/home/node/.openclaw/workspace/nexus", "nexus");
|
||||
ScanDir("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases");
|
||||
|
||||
return Results.Ok(results.OrderByDescending(x => ((DateTime)((dynamic)x).modifiedAt)).Take(100));
|
||||
}
|
||||
=> Results.Ok(docService.GetAll());
|
||||
|
||||
[HttpGet("{**path}")]
|
||||
public async Task<IResult> GetFile(string path)
|
||||
@@ -49,21 +17,7 @@ public class DocsController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return Results.BadRequest("Path required.");
|
||||
|
||||
string? resolvedPath = null;
|
||||
foreach (var root in new[] { "/mnt/workspace-iris", "/home/node/.openclaw/workspace/nexus" })
|
||||
{
|
||||
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && System.IO.File.Exists(candidate))
|
||||
{
|
||||
resolvedPath = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedPath is null)
|
||||
return Results.NotFound();
|
||||
|
||||
var content = await System.IO.File.ReadAllTextAsync(resolvedPath);
|
||||
var fi = new FileInfo(resolvedPath);
|
||||
return Results.Ok(new { name = fi.Name, path = resolvedPath.Replace("/mnt/workspace-iris/", "").Replace("/home/node/.openclaw/workspace/nexus/", ""), content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
||||
var file = await docService.GetFileAsync(path);
|
||||
return file is null ? Results.NotFound() : Results.Ok(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +1,20 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Helpers;
|
||||
using System.Text.RegularExpressions;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/incidents")]
|
||||
public class IncidentsController : ControllerBase
|
||||
public class IncidentsController(IIncidentService incidentService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetAll()
|
||||
{
|
||||
var basePath = "/mnt/workspace-iris/memory/incidents";
|
||||
if (!Directory.Exists(basePath))
|
||||
return Results.Ok(Array.Empty<object>());
|
||||
|
||||
var incidents = new List<object>();
|
||||
foreach (var file in Directory.GetFiles(basePath, "*.md").OrderByDescending(f => f).Take(50))
|
||||
{
|
||||
var fi = new FileInfo(file);
|
||||
if (fi.Length > 1_000_000) continue;
|
||||
var name = Path.GetFileNameWithoutExtension(file);
|
||||
var content = await System.IO.File.ReadAllTextAsync(file);
|
||||
|
||||
var title = name;
|
||||
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
|
||||
if (titleMatch.Success)
|
||||
title = titleMatch.Groups[1].Value.Trim();
|
||||
|
||||
var date = (string?)null;
|
||||
var dateMatch = Regex.Match(name, @"^(\d{4}-\d{2}-\d{2})");
|
||||
if (dateMatch.Success)
|
||||
date = dateMatch.Groups[1].Value;
|
||||
|
||||
var severity = "unknown";
|
||||
var severityMatch = Regex.Match(content, @"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline);
|
||||
if (severityMatch.Success)
|
||||
severity = severityMatch.Groups[1].Value.Trim();
|
||||
|
||||
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
|
||||
var excerpt = excerptEnd > 0
|
||||
? content[..excerptEnd].Trim()
|
||||
: content[..Math.Min(300, content.Length)].Trim();
|
||||
if (excerpt.Length > 200)
|
||||
excerpt = excerpt[..200] + "\u2026";
|
||||
|
||||
incidents.Add(new
|
||||
{
|
||||
name = Path.GetFileName(file),
|
||||
title,
|
||||
date,
|
||||
severity,
|
||||
excerpt,
|
||||
size = fi.Length
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(incidents);
|
||||
}
|
||||
=> Results.Ok(await incidentService.GetAllAsync());
|
||||
|
||||
[HttpGet("{name}")]
|
||||
public async Task<IResult> GetOne(string name)
|
||||
{
|
||||
var basePath = "/mnt/workspace-iris/memory/incidents";
|
||||
if (!PathSecurityHelper.TryResolveSafePath(basePath, name, out var filePath))
|
||||
return Results.BadRequest("Invalid filename.");
|
||||
|
||||
if (!System.IO.File.Exists(filePath!))
|
||||
{
|
||||
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
filePath = Path.Combine(basePath, name + ".md");
|
||||
if (!System.IO.File.Exists(filePath!))
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var content = await System.IO.File.ReadAllTextAsync(filePath!);
|
||||
var fi = new FileInfo(filePath!);
|
||||
var fileName = Path.GetFileName(filePath!);
|
||||
|
||||
var title = fileName;
|
||||
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
|
||||
if (titleMatch.Success)
|
||||
title = titleMatch.Groups[1].Value.Trim();
|
||||
|
||||
var date = (string?)null;
|
||||
var dateMatch = Regex.Match(fileName, @"^(\d{4}-\d{2}-\d{2})");
|
||||
if (dateMatch.Success)
|
||||
date = dateMatch.Groups[1].Value;
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
name = fileName,
|
||||
title,
|
||||
date,
|
||||
content,
|
||||
size = fi.Length
|
||||
});
|
||||
var incident = await incidentService.GetByNameAsync(name);
|
||||
return incident is null ? Results.NotFound() : Results.Ok(incident);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,15 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Helpers;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/memory")]
|
||||
public class MemoryController : ControllerBase
|
||||
public class MemoryController(IMemoryService memoryService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IResult GetAll()
|
||||
{
|
||||
var basePath = "/mnt/workspace-iris/memory";
|
||||
if (!Directory.Exists(basePath))
|
||||
return Results.Ok(Array.Empty<object>());
|
||||
|
||||
var files = Directory.GetFiles(basePath, "*.md")
|
||||
.Select(f => new FileInfo(f))
|
||||
.OrderByDescending(f => f.Name)
|
||||
.Select(f => new
|
||||
{
|
||||
name = f.Name,
|
||||
path = f.FullName.Replace(basePath, "").TrimStart('/'),
|
||||
size = f.Length,
|
||||
modifiedAt = f.LastWriteTimeUtc
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
||||
if (System.IO.File.Exists(longTermPath))
|
||||
{
|
||||
var fi = new FileInfo(longTermPath);
|
||||
files.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
||||
}
|
||||
|
||||
return Results.Ok(files);
|
||||
}
|
||||
public async Task<IResult> GetAll()
|
||||
=> Results.Ok(await memoryService.GetAllAsync());
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<IResult> Search([FromQuery] string q)
|
||||
@@ -42,67 +17,13 @@ public class MemoryController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
|
||||
return Results.BadRequest("Query must be at least 2 characters.");
|
||||
|
||||
var basePath = "/mnt/workspace-iris/memory";
|
||||
var results = new List<object>();
|
||||
|
||||
const int maxFiles = 50;
|
||||
const int maxFileSize = 1_000_000;
|
||||
|
||||
async Task SearchDir(string dir)
|
||||
{
|
||||
if (!Directory.Exists(dir)) return;
|
||||
var files = Directory.GetFiles(dir, "*.md").Take(maxFiles);
|
||||
foreach (var file in files)
|
||||
{
|
||||
var fi = new FileInfo(file);
|
||||
if (fi.Length > maxFileSize) continue;
|
||||
string content;
|
||||
using (var reader = new StreamReader(file))
|
||||
content = await reader.ReadToEndAsync();
|
||||
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
|
||||
var start = Math.Max(0, idx - 60);
|
||||
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
|
||||
results.Add(new { name = Path.GetFileName(file), path = file.Replace(basePath, "").TrimStart('/'), excerpt, size = fi.Length });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await SearchDir(basePath);
|
||||
|
||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
||||
if (System.IO.File.Exists(longTermPath))
|
||||
{
|
||||
string content;
|
||||
using (var reader = new StreamReader(longTermPath))
|
||||
content = await reader.ReadToEndAsync();
|
||||
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
|
||||
var start = Math.Max(0, idx - 60);
|
||||
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
|
||||
results.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", excerpt, size = content.Length });
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(results);
|
||||
return Results.Ok(await memoryService.SearchAsync(q));
|
||||
}
|
||||
|
||||
[HttpGet("{name}")]
|
||||
public async Task<IResult> GetFile(string name)
|
||||
{
|
||||
if (!PathSecurityHelper.TryResolveSafePath("/mnt/workspace-iris/memory", name, out var filePath))
|
||||
return Results.BadRequest("Invalid filename.");
|
||||
|
||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
||||
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
|
||||
filePath = longTermPath;
|
||||
|
||||
if (!System.IO.File.Exists(filePath!))
|
||||
return Results.NotFound();
|
||||
|
||||
var content = await System.IO.File.ReadAllTextAsync(filePath!);
|
||||
return Results.Ok(new { name, path = name, content, size = content.Length, modifiedAt = System.IO.File.GetLastWriteTimeUtc(filePath!) });
|
||||
var file = await memoryService.GetFileAsync(name);
|
||||
return file is null ? Results.NotFound() : Results.Ok(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,13 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/operations")]
|
||||
public class OperationsController(
|
||||
IAgentRuntime runtime,
|
||||
IAgentService agentService,
|
||||
IProjectRepository projectRepo,
|
||||
ITaskRepository taskRepo,
|
||||
IActivityRepository activityRepo) : ControllerBase
|
||||
public class OperationsController(IOperationsService operationsService) : ControllerBase
|
||||
{
|
||||
[HttpGet("snapshot")]
|
||||
public async Task<IResult> GetSnapshot(CancellationToken ct)
|
||||
{
|
||||
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;
|
||||
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
|
||||
|
||||
var runtimeStatus = runtimeTask.Result;
|
||||
var runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online;
|
||||
|
||||
var lastIncident = tasks
|
||||
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||
.OrderByDescending(x => x.UpdatedAt)
|
||||
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
|
||||
.FirstOrDefault();
|
||||
|
||||
var projectHealth = new
|
||||
{
|
||||
Online = projects.Count(x => x.Status == OperationalStatus.Online),
|
||||
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
|
||||
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
|
||||
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
|
||||
};
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
generatedAt = DateTimeOffset.UtcNow,
|
||||
runtime = runtimeStatus,
|
||||
models = Array.Empty<object>(),
|
||||
runtimeHealthy,
|
||||
metrics = new
|
||||
{
|
||||
activeAgents = agents.Count,
|
||||
queuedTasks = tasks.Count - completedTasks,
|
||||
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
|
||||
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||
},
|
||||
lastIncident,
|
||||
projectHealth,
|
||||
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 })
|
||||
});
|
||||
}
|
||||
=> Results.Ok(await operationsService.GetSnapshotAsync(ct));
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/projects")]
|
||||
public class ProjectsController(IProjectRepository projectRepo, IActivityRepository activityRepo) : ControllerBase
|
||||
public class ProjectsController(IProjectService projectService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetAll(CancellationToken ct)
|
||||
=> Results.Ok(await projectRepo.GetAllAsync(ct));
|
||||
=> Results.Ok(await projectService.GetAllAsync(ct));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IResult> GetById(Guid id, CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.GetByIdAsync(id, ct);
|
||||
return project is null ? Results.NotFound() : Results.Ok(project);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
|
||||
@@ -19,59 +25,26 @@ public class ProjectsController(IProjectRepository projectRepo, IActivityReposit
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Name = request.Name.Trim(),
|
||||
Description = request.Description?.Trim() ?? string.Empty,
|
||||
Status = OperationalStatus.Online
|
||||
};
|
||||
await projectRepo.AddAsync(project, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
|
||||
var project = await projectService.CreateAsync(request, ct);
|
||||
return Results.Created($"/api/v1/projects/{project.Id}", project);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IResult> GetById(Guid id, CancellationToken ct)
|
||||
{
|
||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||
return project is null ? Results.NotFound() : Results.Ok(project);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}")]
|
||||
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
|
||||
{
|
||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||
if (project is null) return Results.NotFound();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||
project.Name = request.Name.Trim();
|
||||
if (request.Description is not null)
|
||||
project.Description = request.Description.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
|
||||
project.Status = parsedStatus;
|
||||
|
||||
await projectRepo.UpdateAsync(project, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
|
||||
return Results.Ok(project);
|
||||
var project = await projectService.UpdateAsync(id, request, ct);
|
||||
return project is null ? Results.NotFound() : Results.Ok(project);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||
if (project is null) return Results.NotFound();
|
||||
|
||||
var hasTasks = await projectRepo.HasTasksAsync(id, ct);
|
||||
if (hasTasks)
|
||||
var result = await projectService.DeleteAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
project.Status = OperationalStatus.Offline;
|
||||
await projectRepo.UpdateAsync(project, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct);
|
||||
return Results.Ok(project);
|
||||
}
|
||||
|
||||
await projectRepo.DeleteAsync(project, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
|
||||
return Results.NoContent();
|
||||
ProjectDeleteOutcome.NotFound => Results.NotFound(),
|
||||
ProjectDeleteOutcome.Archived => Results.Ok(result.Project),
|
||||
_ => Results.NoContent()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/tasks")]
|
||||
public class TasksController(ITaskRepository taskRepo, IActivityRepository activityRepo) : ControllerBase
|
||||
public class TasksController(ITaskService taskService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetAll(CancellationToken ct)
|
||||
=> Results.Ok(await taskRepo.GetAllAsync(ct));
|
||||
=> Results.Ok(await taskService.GetAllAsync(ct));
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct)
|
||||
@@ -19,107 +19,84 @@ public class TasksController(ITaskRepository taskRepo, IActivityRepository activ
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
|
||||
|
||||
var task = new WorkTask
|
||||
{
|
||||
Title = request.Title.Trim(),
|
||||
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
|
||||
ProjectId = request.ProjectId
|
||||
};
|
||||
await taskRepo.AddAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
|
||||
var task = await taskService.CreateAsync(request, ct);
|
||||
return Results.Created($"/api/v1/tasks/{task.Id}", task);
|
||||
}
|
||||
|
||||
[HttpGet("pending-approval")]
|
||||
public async Task<IResult> GetPendingApproval(CancellationToken ct)
|
||||
{
|
||||
var pending = await taskRepo.GetPendingApprovalAsync(ct);
|
||||
var pending = await taskService.GetPendingApprovalAsync(ct);
|
||||
return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/approve")]
|
||||
public async Task<IResult> Approve(Guid id, CancellationToken ct)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return Results.NotFound();
|
||||
|
||||
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
||||
return Results.Problem(
|
||||
var result = await taskService.ApproveAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||
title: "Approval denied",
|
||||
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
|
||||
return Results.Ok(task);
|
||||
statusCode: StatusCodes.Status403Forbidden),
|
||||
_ => Results.Ok(result.Task)
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/reject")]
|
||||
public async Task<IResult> Reject(Guid id, CancellationToken ct)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return Results.NotFound();
|
||||
|
||||
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
||||
return Results.Problem(
|
||||
var result = await taskService.RejectAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||
title: "Rejection denied",
|
||||
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
|
||||
return Results.Ok(task);
|
||||
statusCode: StatusCodes.Status403Forbidden),
|
||||
_ => Results.Ok(result.Task)
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/state")]
|
||||
public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
|
||||
{
|
||||
var allowedStates = TaskStateHelper.AllStates;
|
||||
if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase))
|
||||
if (!TaskStateHelper.IsValidState(request.State))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] });
|
||||
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return Results.NotFound();
|
||||
task.State = allowedStates.First(x => x.Equals(request.State, StringComparison.OrdinalIgnoreCase));
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
|
||||
return Results.Ok(task);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return Results.NotFound();
|
||||
|
||||
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
||||
return Results.Problem(
|
||||
title: "Task deletion denied",
|
||||
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
|
||||
await taskRepo.DeleteAsync(task, ct);
|
||||
return Results.NoContent();
|
||||
var result = await taskService.UpdateStateAsync(id, request.State, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
_ => Results.Ok(result.Task)
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}")]
|
||||
public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return Results.NotFound();
|
||||
var result = await taskService.UpdateAsync(id, request, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
_ => Results.Ok(result.Task)
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||
task.Title = request.Title.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Priority))
|
||||
task.Priority = request.Priority.Trim();
|
||||
if (request.ProjectId.HasValue)
|
||||
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
|
||||
return Results.Ok(task);
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await taskService.DeleteAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||
title: "Task deletion denied",
|
||||
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
|
||||
statusCode: StatusCodes.Status403Forbidden),
|
||||
_ => Results.NoContent()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,36 +5,9 @@ namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/team")]
|
||||
public class TeamController(IAgentService agentService) : ControllerBase
|
||||
public class TeamController(ITeamService teamService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetTeam(CancellationToken ct)
|
||||
{
|
||||
var agents = await agentService.GetAgentsAsync(ct);
|
||||
var team = new List<object>();
|
||||
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
string identity = "";
|
||||
string workspace = agent.Workspace ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace))
|
||||
{
|
||||
var identityFile = Path.Combine(workspace, "IDENTITY.md");
|
||||
if (System.IO.File.Exists(identityFile))
|
||||
{
|
||||
var content = await System.IO.File.ReadAllTextAsync(identityFile, ct);
|
||||
var lines = content.Split('\n').Where(l => l.StartsWith("- **")).Take(8);
|
||||
identity = string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
|
||||
team.Add(new
|
||||
{
|
||||
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
|
||||
identity
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(team);
|
||||
}
|
||||
=> Results.Ok(await teamService.GetTeamAsync(ct));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ public sealed record DashboardAgentInfo(
|
||||
string[] Tags,
|
||||
int Progress = 0,
|
||||
int Workload = 0,
|
||||
string? Goal = null
|
||||
string? Goal = null,
|
||||
string RoleBadge = "badge-slate",
|
||||
string StatusLabel = "Bereit",
|
||||
string? Elapsed = null,
|
||||
string? Think = null,
|
||||
string? Next = null
|
||||
);
|
||||
|
||||
public sealed record MessageEntry(
|
||||
|
||||
+11
-1
@@ -112,7 +112,7 @@ builder.Services.AddHttpClient("gateway", client =>
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<OpenClawGatewayClient>(client =>
|
||||
builder.Services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
@@ -123,6 +123,16 @@ builder.Services.AddHttpClient<OpenClawGatewayClient>(client =>
|
||||
builder.Services.AddTransient<ModelRoutingService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IAgentService, AgentService>();
|
||||
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||||
builder.Services.AddScoped<IProjectService, ProjectService>();
|
||||
builder.Services.AddScoped<ITaskService, TaskService>();
|
||||
builder.Services.AddScoped<IOperationsService, OperationsService>();
|
||||
builder.Services.AddScoped<ITeamService, TeamService>();
|
||||
builder.Services.AddSingleton<IAgentConfigService, AgentConfigService>();
|
||||
builder.Services.AddSingleton<IMemoryService, MemoryService>();
|
||||
builder.Services.AddSingleton<IIncidentService, IncidentService>();
|
||||
builder.Services.AddSingleton<IDocService, DocService>();
|
||||
builder.Services.AddScoped<ICalendarService, CalendarService>();
|
||||
|
||||
// --- Repositories ---
|
||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||
|
||||
@@ -10,12 +10,11 @@ public interface IUserRepository
|
||||
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
|
||||
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
|
||||
|
||||
// Refresh token operations
|
||||
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
||||
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
|
||||
Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
||||
Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
||||
Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default);
|
||||
Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default);
|
||||
Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,33 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
||||
public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default)
|
||||
=> db.SaveChangesAsync(ct);
|
||||
|
||||
public async Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default)
|
||||
{
|
||||
var token = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct);
|
||||
if (token is null || token.RevokedAt is not null) return;
|
||||
|
||||
token.RevokedAt = DateTimeOffset.UtcNow;
|
||||
token.ConcurrencyStamp = Guid.NewGuid();
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default)
|
||||
{
|
||||
var activeTokens = await db.RefreshTokens
|
||||
.Where(r => r.FamilyId == familyId && r.RevokedAt == null)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (activeTokens.Count == 0) return;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
token.ConcurrencyStamp = Guid.NewGuid();
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
|
||||
@@ -51,9 +78,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (oldTokens.Count > 0)
|
||||
{
|
||||
db.RefreshTokens.RemoveRange(oldTokens);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken ct = default)
|
||||
=> db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using Nexus.Api.Helpers;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class AgentConfigService : IAgentConfigService
|
||||
{
|
||||
private static readonly HashSet<string> AllowedFiles = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
|
||||
};
|
||||
|
||||
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId)
|
||||
{
|
||||
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||
if (!Directory.Exists(workspacePath))
|
||||
return Array.Empty<AgentConfigFileInfo>();
|
||||
|
||||
return Directory.GetFiles(workspacePath, "*.md")
|
||||
.Select(f => new FileInfo(f))
|
||||
.Where(f => AllowedFiles.Contains(f.Name))
|
||||
.OrderBy(f => f.Name)
|
||||
.Select(f => new AgentConfigFileInfo(f.Name, f.Length, f.LastWriteTimeUtc))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
|
||||
{
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
return null;
|
||||
|
||||
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
|
||||
return null;
|
||||
|
||||
var content = await File.ReadAllTextAsync(safePath!, ct);
|
||||
var fi = new FileInfo(safePath!);
|
||||
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
|
||||
}
|
||||
|
||||
public async Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
|
||||
{
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
return null;
|
||||
|
||||
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
|
||||
return null;
|
||||
|
||||
var tempPath = safePath + ".tmp";
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(tempPath, content, ct);
|
||||
File.Move(tempPath, safePath!, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (File.Exists(tempPath)) File.Delete(tempPath);
|
||||
throw;
|
||||
}
|
||||
|
||||
var fi = new FileInfo(safePath!);
|
||||
return new AgentConfigFileSaveResult(fileName, fi.Length, fi.LastWriteTimeUtc);
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ public sealed class AuthService : IAuthService
|
||||
|
||||
if (token.RevokedAt is not null)
|
||||
{
|
||||
await RevokeFamilyAsync(token.FamilyId, ct);
|
||||
await _users.RevokeFamilyAsync(token.FamilyId, ct);
|
||||
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
|
||||
return null;
|
||||
}
|
||||
@@ -84,23 +84,12 @@ public sealed class AuthService : IAuthService
|
||||
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refreshToken)) return;
|
||||
|
||||
var tokenHash = HashToken(refreshToken);
|
||||
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct);
|
||||
if (token is null || token.RevokedAt is not null) return;
|
||||
|
||||
token.RevokedAt = DateTimeOffset.UtcNow;
|
||||
token.ConcurrencyStamp = Guid.NewGuid();
|
||||
await _users.SaveChangesAsync(ct);
|
||||
await _users.RevokeTokenAsync(tokenHash, ct);
|
||||
}
|
||||
|
||||
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
|
||||
=> Task.Run(async () =>
|
||||
{
|
||||
// AsNoTracking equivalent: UserRepository.GetByIdAsync uses FindAsync (tracked by default)
|
||||
// For read-only access, we call it but the result shouldn't be mutated
|
||||
return await _users.GetByIdAsync(userId, ct);
|
||||
}, ct);
|
||||
=> _users.GetByIdAsync(userId, ct).AsTask();
|
||||
|
||||
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
|
||||
{
|
||||
@@ -228,19 +217,6 @@ public sealed class AuthService : IAuthService
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct)
|
||||
{
|
||||
var activeTokens = await _users.GetActiveTokensByFamilyAsync(familyId, ct);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
token.ConcurrencyStamp = Guid.NewGuid();
|
||||
}
|
||||
|
||||
await _users.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static string GenerateRefreshToken()
|
||||
{
|
||||
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Nexus.Api.DTOs;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class CalendarService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
ILogger<CalendarService> logger) : ICalendarService
|
||||
{
|
||||
public async Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = CreateGatewayClient();
|
||||
var response = await client.GetAsync("/api/cron", ct);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
|
||||
return data ?? new List<CronJobEntry>();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data");
|
||||
}
|
||||
|
||||
return BuildFallbackCronJobs();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = CreateGatewayClient();
|
||||
var response = await client.GetAsync("/api/cron/upcoming", ct);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
|
||||
return data ?? new List<UpcomingCronEntry>();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data");
|
||||
}
|
||||
|
||||
return BuildFallbackUpcomingJobs();
|
||||
}
|
||||
|
||||
private HttpClient CreateGatewayClient()
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("gateway");
|
||||
var token = configuration["Integrations:OpenClaw:Token"];
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CronJobEntry> BuildFallbackCronJobs()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return
|
||||
[
|
||||
new("health-check", "Health Check", "*/5 * * * *", now.AddMinutes(-3).ToString("O"), now.AddMinutes(2).ToString("O"), "completed"),
|
||||
new("memory-sync", "Memory Sync", "0 */6 * * *", now.AddHours(-2).ToString("O"), now.AddHours(4).ToString("O"), "completed"),
|
||||
new("task-cleanup", "Task Cleanup", "0 3 * * *", now.AddDays(-1).ToString("O"), now.AddDays(1).AddHours(3).ToString("O"), "completed"),
|
||||
new("backup", "Database Backup", "0 4 * * *", now.AddDays(-1).AddHours(-1).ToString("O"), now.AddDays(1).AddHours(4).ToString("O"), "completed"),
|
||||
new("model-routing-refresh", "Model Routing Refresh", "*/30 * * * *", now.AddMinutes(-12).ToString("O"), now.AddMinutes(18).ToString("O"), "running")
|
||||
];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<UpcomingCronEntry> BuildFallbackUpcomingJobs()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return
|
||||
[
|
||||
new("health-check", "Health Check", now.AddMinutes(2).ToString("O"), "*/5 * * * *"),
|
||||
new("model-routing-refresh", "Model Routing Refresh", now.AddMinutes(18).ToString("O"), "*/30 * * * *"),
|
||||
new("memory-sync", "Memory Sync", now.AddHours(4).ToString("O"), "0 */6 * * *"),
|
||||
new("task-cleanup", "Task Cleanup", now.AddDays(1).AddHours(3).ToString("O"), "0 3 * * *"),
|
||||
new("backup", "Database Backup", now.AddDays(1).AddHours(4).ToString("O"), "0 4 * * *")
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class DashboardService(
|
||||
IOpenClawGatewayClient gateway,
|
||||
ITaskService taskService,
|
||||
ILogger<DashboardService> logger) : IDashboardService
|
||||
{
|
||||
public async Task<DashboardStatus> GetStatusAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetStatusAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard status check failed");
|
||||
return new DashboardStatus(false, "Offline", 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetAgentsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard agents fetch failed");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agentFilter))
|
||||
{
|
||||
entries = entries
|
||||
.Where(e => string.Equals(e.AgentId, agentFilter, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(e.Agent, agentFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard operations fetch failed");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ChatResponse> SendChatAsync(string agentId, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.SendChatMessageAsync(agentId, message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard chat send failed");
|
||||
return new ChatResponse(false, null, "Gateway nicht erreichbar");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset)
|
||||
{
|
||||
try
|
||||
{
|
||||
var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim();
|
||||
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
|
||||
return messages
|
||||
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard messages fetch failed");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<QueueItem>> GetQueueAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cronTask = gateway.GetQueueAsync();
|
||||
var tasksTask = taskService.GetOpenAsync(ct);
|
||||
await Task.WhenAll(cronTask, tasksTask);
|
||||
|
||||
var merged = new List<QueueItem>(cronTask.Result);
|
||||
foreach (var t in tasksTask.Result)
|
||||
{
|
||||
merged.Add(new QueueItem("task-" + t.Id, t.Title, t.State, NormalizePriority(t.Priority), "task", "--"));
|
||||
}
|
||||
|
||||
return merged
|
||||
.OrderBy(q => PriorityOrder.GetValueOrDefault(q.Priority, 99))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard queue fetch failed");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
|
||||
{
|
||||
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var ok = await gateway.DeleteCronJobAsync(id);
|
||||
return new QueueDeleteResult(ok ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.GatewayError);
|
||||
}
|
||||
|
||||
if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase) || id.StartsWith("task-"))
|
||||
{
|
||||
if (!id.StartsWith("task-")) return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
|
||||
if (!Guid.TryParse(id["task-".Length..], out var guid))
|
||||
return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
|
||||
|
||||
var result = await taskService.CompleteViaQueueAsync(guid, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => new QueueDeleteResult(QueueDeleteOutcome.TaskNotFound),
|
||||
_ => new QueueDeleteResult(QueueDeleteOutcome.Deleted)
|
||||
};
|
||||
}
|
||||
|
||||
var deleted = await gateway.DeleteCronJobAsync(id);
|
||||
return new QueueDeleteResult(deleted ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.NotFound);
|
||||
}
|
||||
|
||||
public async Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct)
|
||||
{
|
||||
if (!id.StartsWith("task-"))
|
||||
return new QueuePriorityResult(QueuePriorityOutcome.Ignored);
|
||||
|
||||
if (!Guid.TryParse(id["task-".Length..], out var guid))
|
||||
return new QueuePriorityResult(QueuePriorityOutcome.InvalidTaskId);
|
||||
|
||||
var result = await taskService.CyclePriorityAsync(guid, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => new QueuePriorityResult(QueuePriorityOutcome.TaskNotFound),
|
||||
_ => new QueuePriorityResult(QueuePriorityOutcome.Updated, result.Task?.Priority)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<AgentModelInfo?> GetAgentModelAsync(string agentId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetAgentModelAsync(agentId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", agentId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SetAgentModelAsync(string agentId, string model)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.SetAgentModelAsync(agentId, model);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", agentId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetAgentActivityAsync(agentId, Math.Clamp(limit, 1, 20));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", agentId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public List<ModelOption> GetAvailableModels() => gateway.GetAvailableModels();
|
||||
|
||||
private static string NormalizePriority(string priority) => priority.ToLowerInvariant() switch
|
||||
{
|
||||
"high" or "critical" or "urgent" => "high",
|
||||
"low" or "minor" => "low",
|
||||
_ => "medium"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, int> PriorityOrder = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["high"] = 0, ["medium"] = 1, ["low"] = 2
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Nexus.Api.Helpers;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class DocService : IDocService
|
||||
{
|
||||
private static readonly string[] AllowedExtensions = [".md", ".json", ".txt", ".yaml", ".yml", ".html", ".css"];
|
||||
private static readonly string[] SearchRoots =
|
||||
[
|
||||
"/mnt/workspace-iris",
|
||||
"/home/node/.openclaw/workspace/nexus"
|
||||
];
|
||||
|
||||
private static readonly (string Dir, string Category)[] ScanDirectories =
|
||||
[
|
||||
("/mnt/workspace-iris/nexus-phases", "phases"),
|
||||
("/mnt/workspace-iris/skills", "skills"),
|
||||
("/mnt/workspace-iris", "workspace"),
|
||||
("/home/node/.openclaw/workspace/nexus", "nexus"),
|
||||
("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases")
|
||||
];
|
||||
|
||||
public IReadOnlyList<DocFileInfo> GetAll()
|
||||
{
|
||||
var results = new List<DocFileInfo>();
|
||||
|
||||
foreach (var (dir, category) in ScanDirectories)
|
||||
{
|
||||
if (!Directory.Exists(dir)) continue;
|
||||
foreach (var file in Directory.GetFiles(dir, "*.*"))
|
||||
{
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext)) continue;
|
||||
|
||||
var fi = new FileInfo(file);
|
||||
results.Add(new DocFileInfo(
|
||||
fi.Name,
|
||||
file.Replace("/mnt/workspace-iris", "").TrimStart('/'),
|
||||
category,
|
||||
ext.Replace(".", ""),
|
||||
fi.Length,
|
||||
fi.LastWriteTimeUtc));
|
||||
}
|
||||
}
|
||||
|
||||
return results.OrderByDescending(x => x.ModifiedAt).Take(100).ToList();
|
||||
}
|
||||
|
||||
public async Task<DocFileContent?> GetFileAsync(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return null;
|
||||
|
||||
string? resolvedPath = null;
|
||||
foreach (var root in SearchRoots)
|
||||
{
|
||||
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && File.Exists(candidate))
|
||||
{
|
||||
resolvedPath = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedPath is null)
|
||||
return null;
|
||||
|
||||
var content = await File.ReadAllTextAsync(resolvedPath);
|
||||
var fi = new FileInfo(resolvedPath);
|
||||
var relativePath = resolvedPath
|
||||
.Replace("/mnt/workspace-iris/", "")
|
||||
.Replace("/home/node/.openclaw/workspace/nexus/", "");
|
||||
|
||||
return new DocFileContent(fi.Name, relativePath, content, fi.Length, fi.LastWriteTimeUtc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime ModifiedAt);
|
||||
|
||||
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
|
||||
|
||||
public sealed record AgentConfigFileSaveResult(string FileName, long Size, DateTime ModifiedAt);
|
||||
|
||||
public interface IAgentConfigService
|
||||
{
|
||||
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
|
||||
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
|
||||
Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Nexus.Api.DTOs;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public interface ICalendarService
|
||||
{
|
||||
Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default);
|
||||
Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public enum QueueDeleteOutcome { Deleted, NotFound, GatewayError, TaskNotFound, InvalidTaskId, Ignored }
|
||||
public enum QueuePriorityOutcome { Updated, Ignored, TaskNotFound, InvalidTaskId }
|
||||
|
||||
public sealed record QueueDeleteResult(QueueDeleteOutcome Outcome);
|
||||
public sealed record QueuePriorityResult(QueuePriorityOutcome Outcome, string? NewPriority = null);
|
||||
|
||||
public interface IDashboardService
|
||||
{
|
||||
Task<DashboardStatus> GetStatusAsync();
|
||||
Task<List<DashboardAgentInfo>> GetAgentsAsync();
|
||||
Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter);
|
||||
Task<ChatResponse> SendChatAsync(string agentId, string message);
|
||||
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
|
||||
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
|
||||
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
|
||||
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
|
||||
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||
Task<bool> SetAgentModelAsync(string agentId, string model);
|
||||
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit);
|
||||
List<ModelOption> GetAvailableModels();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed record DocFileInfo(
|
||||
string Name,
|
||||
string Path,
|
||||
string Category,
|
||||
string Type,
|
||||
long Size,
|
||||
DateTime ModifiedAt);
|
||||
|
||||
public sealed record DocFileContent(
|
||||
string Name,
|
||||
string Path,
|
||||
string Content,
|
||||
long Size,
|
||||
DateTime ModifiedAt);
|
||||
|
||||
public interface IDocService
|
||||
{
|
||||
IReadOnlyList<DocFileInfo> GetAll();
|
||||
Task<DocFileContent?> GetFileAsync(string path);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed record IncidentSummary(
|
||||
string Name,
|
||||
string Title,
|
||||
string? Date,
|
||||
string Severity,
|
||||
string Excerpt,
|
||||
long Size);
|
||||
|
||||
public sealed record IncidentDetail(
|
||||
string Name,
|
||||
string Title,
|
||||
string? Date,
|
||||
string Content,
|
||||
long Size);
|
||||
|
||||
public interface IIncidentService
|
||||
{
|
||||
Task<IReadOnlyList<IncidentSummary>> GetAllAsync();
|
||||
Task<IncidentDetail?> GetByNameAsync(string name);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed record MemoryFileInfo(string Name, string Path, long Size, DateTime ModifiedAt);
|
||||
|
||||
public sealed record MemoryFileContent(string Name, string Path, string Content, long Size, DateTime ModifiedAt);
|
||||
|
||||
public sealed record MemorySearchResult(string Name, string Path, string Excerpt, long Size);
|
||||
|
||||
public interface IMemoryService
|
||||
{
|
||||
Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync();
|
||||
Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query);
|
||||
Task<MemoryFileContent?> GetFileAsync(string name);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public interface IOpenClawGatewayClient
|
||||
{
|
||||
Task<JsonNode?> InvokeToolAsync(string tool, object? args = null);
|
||||
Task<DashboardStatus> GetStatusAsync();
|
||||
Task<List<DashboardAgentInfo>> GetAgentsAsync();
|
||||
Task<List<MessageEntry>> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0);
|
||||
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
|
||||
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
|
||||
Task<List<QueueItem>> GetQueueAsync();
|
||||
Task<bool> DeleteCronJobAsync(string id);
|
||||
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||
Task<bool> SetAgentModelAsync(string agentId, string model);
|
||||
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit = 5);
|
||||
List<ModelOption> GetAvailableModels();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public interface IOperationsService
|
||||
{
|
||||
Task<object> GetSnapshotAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public enum ProjectDeleteOutcome { NotFound, Deleted, Archived }
|
||||
|
||||
public sealed record ProjectDeleteResult(ProjectDeleteOutcome Outcome, Project? Project = null);
|
||||
|
||||
public interface IProjectService
|
||||
{
|
||||
Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default);
|
||||
Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default);
|
||||
Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public enum TaskOperationOutcome { Success, NotFound, InvalidState }
|
||||
|
||||
public sealed record TaskOperationResult(TaskOperationOutcome Outcome, WorkTask? Task = null);
|
||||
|
||||
public interface ITaskService
|
||||
{
|
||||
Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
|
||||
Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
// Dashboard-facing task operations
|
||||
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
|
||||
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Nexus.Api.Data;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed record TeamMember(
|
||||
string Id,
|
||||
string Name,
|
||||
string Role,
|
||||
string Model,
|
||||
OperationalStatus Status,
|
||||
DateTimeOffset? LastSeen,
|
||||
string? Workspace,
|
||||
string? Description,
|
||||
string Identity);
|
||||
|
||||
public interface ITeamService
|
||||
{
|
||||
Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Nexus.Api.Helpers;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed partial class IncidentService : IIncidentService
|
||||
{
|
||||
private const string BasePath = "/mnt/workspace-iris/memory/incidents";
|
||||
|
||||
public async Task<IReadOnlyList<IncidentSummary>> GetAllAsync()
|
||||
{
|
||||
if (!Directory.Exists(BasePath))
|
||||
return Array.Empty<IncidentSummary>();
|
||||
|
||||
var incidents = new List<IncidentSummary>();
|
||||
foreach (var file in Directory.GetFiles(BasePath, "*.md").OrderByDescending(f => f).Take(50))
|
||||
{
|
||||
var fi = new FileInfo(file);
|
||||
if (fi.Length > 1_000_000) continue;
|
||||
|
||||
var name = Path.GetFileNameWithoutExtension(file);
|
||||
var content = await File.ReadAllTextAsync(file);
|
||||
var title = ExtractTitle(name, content);
|
||||
var date = ExtractDate(name);
|
||||
var severity = ExtractSeverity(content);
|
||||
var excerpt = ExtractExcerpt(content);
|
||||
|
||||
incidents.Add(new IncidentSummary(Path.GetFileName(file), title, date, severity, excerpt, fi.Length));
|
||||
}
|
||||
|
||||
return incidents;
|
||||
}
|
||||
|
||||
public async Task<IncidentDetail?> GetByNameAsync(string name)
|
||||
{
|
||||
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out var filePath))
|
||||
return null;
|
||||
|
||||
if (!File.Exists(filePath!))
|
||||
{
|
||||
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
filePath = Path.Combine(BasePath, name + ".md");
|
||||
if (!File.Exists(filePath!))
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(filePath!);
|
||||
var fi = new FileInfo(filePath!);
|
||||
var fileName = Path.GetFileName(filePath!);
|
||||
var title = ExtractTitle(Path.GetFileNameWithoutExtension(filePath!), content);
|
||||
var date = ExtractDate(fileName);
|
||||
|
||||
return new IncidentDetail(fileName, title, date, content, fi.Length);
|
||||
}
|
||||
|
||||
private static string ExtractTitle(string name, string content)
|
||||
{
|
||||
var match = TitleRegex().Match(content);
|
||||
return match.Success ? match.Groups[1].Value.Trim() : name;
|
||||
}
|
||||
|
||||
private static string? ExtractDate(string fileName)
|
||||
{
|
||||
var match = DateRegex().Match(fileName);
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
private static string ExtractSeverity(string content)
|
||||
{
|
||||
var match = SeverityRegex().Match(content);
|
||||
return match.Success ? match.Groups[1].Value.Trim() : "unknown";
|
||||
}
|
||||
|
||||
private static string ExtractExcerpt(string content)
|
||||
{
|
||||
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
|
||||
var excerpt = excerptEnd > 0 ? content[..excerptEnd].Trim() : content[..Math.Min(300, content.Length)].Trim();
|
||||
return excerpt.Length > 200 ? excerpt[..200] + "…" : excerpt;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^#\s+(.+)$", RegexOptions.Multiline)]
|
||||
private static partial Regex TitleRegex();
|
||||
|
||||
[GeneratedRegex(@"^(\d{4}-\d{2}-\d{2})")]
|
||||
private static partial Regex DateRegex();
|
||||
|
||||
[GeneratedRegex(@"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline)]
|
||||
private static partial Regex SeverityRegex();
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Nexus.Api.Helpers;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class MemoryService : IMemoryService
|
||||
{
|
||||
private const string BasePath = "/mnt/workspace-iris/memory";
|
||||
private const string LongTermPath = "/mnt/workspace-iris/MEMORY.md";
|
||||
private const int MaxFileSize = 1_000_000;
|
||||
private const int MaxFiles = 50;
|
||||
|
||||
public Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync()
|
||||
{
|
||||
var files = new List<MemoryFileInfo>();
|
||||
|
||||
if (File.Exists(LongTermPath))
|
||||
{
|
||||
var fi = new FileInfo(LongTermPath);
|
||||
files.Add(new MemoryFileInfo("MEMORY.md", "MEMORY.md", fi.Length, fi.LastWriteTimeUtc));
|
||||
}
|
||||
|
||||
if (Directory.Exists(BasePath))
|
||||
{
|
||||
var memFiles = Directory.GetFiles(BasePath, "*.md")
|
||||
.Select(f => new FileInfo(f))
|
||||
.OrderByDescending(f => f.Name)
|
||||
.Select(f => new MemoryFileInfo(
|
||||
f.Name,
|
||||
f.FullName.Replace(BasePath, "").TrimStart('/'),
|
||||
f.Length,
|
||||
f.LastWriteTimeUtc));
|
||||
files.AddRange(memFiles);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<MemoryFileInfo>>(files);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query)
|
||||
{
|
||||
var results = new List<MemorySearchResult>();
|
||||
|
||||
async Task SearchDir(string dir)
|
||||
{
|
||||
if (!Directory.Exists(dir)) return;
|
||||
foreach (var file in Directory.GetFiles(dir, "*.md").Take(MaxFiles))
|
||||
{
|
||||
var fi = new FileInfo(file);
|
||||
if (fi.Length > MaxFileSize) continue;
|
||||
var content = await File.ReadAllTextAsync(file);
|
||||
if (!content.Contains(query, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
|
||||
var start = Math.Max(0, idx - 60);
|
||||
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
|
||||
results.Add(new MemorySearchResult(
|
||||
Path.GetFileName(file),
|
||||
file.Replace(BasePath, "").TrimStart('/'),
|
||||
excerpt,
|
||||
fi.Length));
|
||||
}
|
||||
}
|
||||
|
||||
await SearchDir(BasePath);
|
||||
|
||||
if (File.Exists(LongTermPath))
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(LongTermPath);
|
||||
if (content.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
|
||||
var start = Math.Max(0, idx - 60);
|
||||
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
|
||||
results.Insert(0, new MemorySearchResult("MEMORY.md", "MEMORY.md", excerpt, content.Length));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<MemoryFileContent?> GetFileAsync(string name)
|
||||
{
|
||||
string? filePath;
|
||||
|
||||
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filePath = LongTermPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out filePath))
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!File.Exists(filePath!))
|
||||
return null;
|
||||
|
||||
var content = await File.ReadAllTextAsync(filePath!);
|
||||
return new MemoryFileContent(name, name, content, content.Length, File.GetLastWriteTimeUtc(filePath!));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration)
|
||||
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -202,7 +202,12 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
Tags: tags,
|
||||
Progress: progress,
|
||||
Workload: workload,
|
||||
Goal: goal
|
||||
Goal: goal,
|
||||
RoleBadge: DeriveRoleBadge(id),
|
||||
StatusLabel: DeriveStatusLabel(isActive, status),
|
||||
Elapsed: FormatElapsed(status),
|
||||
Think: null,
|
||||
Next: DeriveNext(isActive, currentTask)
|
||||
));
|
||||
}
|
||||
return agents;
|
||||
@@ -415,7 +420,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
if (toolResult is null)
|
||||
return result;
|
||||
|
||||
var json = toolResult.ToJsonString(); result.Add(new MessageEntry("diag", "JSON[" + json.Substring(0, Math.Min(200, json.Length)) + "]", DateTimeOffset.UtcNow.ToString("o")));
|
||||
var json = toolResult.ToJsonString();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
@@ -840,8 +845,8 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
|
||||
// 3. Look for "## Oberstes Prinzip" as second choice
|
||||
inRoleSection = false;
|
||||
reader = new StringReader(soul);
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
using var reader2 = new StringReader(soul);
|
||||
while ((line = reader2.ReadLine()) is not null)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith("## ") && trimmed.IndexOf("Prinzip", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
@@ -1060,6 +1065,50 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
return slash > 0 ? modelId[..slash] : "unknown";
|
||||
}
|
||||
|
||||
private static string DeriveRoleBadge(string agentId) => agentId.ToLowerInvariant() switch
|
||||
{
|
||||
"iris" => "badge-purple",
|
||||
"programmer" or "developer" => "badge-blue",
|
||||
"reviewer" => "badge-amber",
|
||||
"architekt" => "badge-cyan",
|
||||
"executor" => "badge-rose",
|
||||
"researcher" => "badge-green",
|
||||
_ => "badge-slate"
|
||||
};
|
||||
|
||||
private static string DeriveStatusLabel(bool isActive, JsonNode? status)
|
||||
{
|
||||
if (!isActive) return "Bereit";
|
||||
var statusText = status?["status"]?.GetValue<string>()?.ToLowerInvariant();
|
||||
return statusText switch
|
||||
{
|
||||
"thinking" or "think" => "Plant",
|
||||
"blocked" or "block" => "Blockiert",
|
||||
_ => "Arbeitet"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FormatElapsed(JsonNode? status)
|
||||
{
|
||||
var lastActivity = status?["lastActivity"]?.GetValue<string>()
|
||||
?? status?["lastMessage"]?.GetValue<string>();
|
||||
if (lastActivity is null) return null;
|
||||
if (!DateTimeOffset.TryParse(lastActivity, out var ts)) return null;
|
||||
var diff = DateTimeOffset.UtcNow - ts;
|
||||
if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h";
|
||||
return $"{(int)diff.TotalDays}d";
|
||||
}
|
||||
|
||||
private static string DeriveNext(bool isActive, string? currentTask)
|
||||
{
|
||||
if (!isActive) return "Standby";
|
||||
if (!string.IsNullOrWhiteSpace(currentTask) && currentTask != "Working...")
|
||||
return currentTask.Length > 60 ? currentTask[..60] + "…" : currentTask;
|
||||
return "Aufgabe ausführen";
|
||||
}
|
||||
|
||||
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
||||
{
|
||||
"iris" => "Chief of Staff",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Repositories;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class OperationsService(
|
||||
IAgentRuntime runtime,
|
||||
IAgentService agentService,
|
||||
IProjectRepository projectRepo,
|
||||
ITaskRepository taskRepo,
|
||||
IActivityRepository activityRepo) : IOperationsService
|
||||
{
|
||||
public async Task<object> GetSnapshotAsync(CancellationToken ct = default)
|
||||
{
|
||||
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;
|
||||
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
|
||||
var runtimeStatus = runtimeTask.Result;
|
||||
|
||||
var lastIncident = tasks
|
||||
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||
.OrderByDescending(x => x.UpdatedAt)
|
||||
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
|
||||
.FirstOrDefault();
|
||||
|
||||
return new
|
||||
{
|
||||
generatedAt = DateTimeOffset.UtcNow,
|
||||
runtime = runtimeStatus,
|
||||
models = Array.Empty<object>(),
|
||||
runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online,
|
||||
metrics = new
|
||||
{
|
||||
activeAgents = agents.Count,
|
||||
queuedTasks = tasks.Count - completedTasks,
|
||||
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
|
||||
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||
},
|
||||
lastIncident,
|
||||
projectHealth = new
|
||||
{
|
||||
Online = projects.Count(x => x.Status == OperationalStatus.Online),
|
||||
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
|
||||
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
|
||||
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
|
||||
},
|
||||
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 })
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Repositories;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class ProjectService(
|
||||
IProjectRepository projectRepo,
|
||||
IActivityRepository activityRepo) : IProjectService
|
||||
{
|
||||
public async Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default)
|
||||
=> await projectRepo.GetAllAsync(ct);
|
||||
|
||||
public async Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
=> await projectRepo.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var project = new Project
|
||||
{
|
||||
Name = request.Name.Trim(),
|
||||
Description = request.Description?.Trim() ?? string.Empty,
|
||||
Status = OperationalStatus.Online
|
||||
};
|
||||
await projectRepo.AddAsync(project, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
|
||||
return project;
|
||||
}
|
||||
|
||||
public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||
if (project is null) return null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||
project.Name = request.Name.Trim();
|
||||
if (request.Description is not null)
|
||||
project.Description = request.Description.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
|
||||
project.Status = parsedStatus;
|
||||
|
||||
await projectRepo.UpdateAsync(project, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
|
||||
return project;
|
||||
}
|
||||
|
||||
public async Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||
if (project is null) return new ProjectDeleteResult(ProjectDeleteOutcome.NotFound);
|
||||
|
||||
if (await projectRepo.HasTasksAsync(id, ct))
|
||||
{
|
||||
project.Status = OperationalStatus.Offline;
|
||||
await projectRepo.UpdateAsync(project, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct);
|
||||
return new ProjectDeleteResult(ProjectDeleteOutcome.Archived, project);
|
||||
}
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
|
||||
await projectRepo.DeleteAsync(project, ct);
|
||||
return new ProjectDeleteResult(ProjectDeleteOutcome.Deleted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Repositories;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class TaskService(
|
||||
ITaskRepository taskRepo,
|
||||
IActivityRepository activityRepo) : ITaskService
|
||||
{
|
||||
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||
=> await taskRepo.GetAllAsync(ct);
|
||||
|
||||
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
=> await taskRepo.GetByIdAsync(id, ct);
|
||||
|
||||
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
||||
=> await taskRepo.GetPendingApprovalAsync(ct);
|
||||
|
||||
public async Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var task = new WorkTask
|
||||
{
|
||||
Title = request.Title.Trim(),
|
||||
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
|
||||
ProjectId = request.ProjectId
|
||||
};
|
||||
await taskRepo.AddAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
public async Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
public async Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default)
|
||||
{
|
||||
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(state, StringComparison.OrdinalIgnoreCase));
|
||||
if (canonical is null) return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
task.State = canonical;
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||
task.Title = request.Title.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Priority))
|
||||
task.Priority = request.Priority.Trim();
|
||||
if (request.ProjectId.HasValue)
|
||||
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
public async Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
|
||||
await taskRepo.DeleteAsync(task, ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success);
|
||||
}
|
||||
|
||||
// ── Dashboard-facing operations ──
|
||||
|
||||
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
|
||||
{
|
||||
var all = await taskRepo.GetAllAsync(ct);
|
||||
return all.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<WorkTask> CreateDashboardTaskAsync(
|
||||
string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
|
||||
{
|
||||
var task = new WorkTask
|
||||
{
|
||||
Title = title.Trim(),
|
||||
Detail = detail?.Trim(),
|
||||
Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(),
|
||||
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
|
||||
AssignedTo = assignedTo?.Trim()
|
||||
};
|
||||
await taskRepo.AddAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" created ({task.Source})" }, ct);
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<TaskOperationResult> UpdateDashboardTaskAsync(
|
||||
Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title)) task.Title = title.Trim();
|
||||
if (detail is not null) task.Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(source)) task.Source = source.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(priority)) task.Priority = priority.Trim();
|
||||
if (assignedTo is not null) task.AssignedTo = string.IsNullOrWhiteSpace(assignedTo) ? null : assignedTo.Trim();
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated" }, ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
public async Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default)
|
||||
{
|
||||
if (!TaskStateHelper.IsValidState(status))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
|
||||
task.State = canonical;
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}" }, ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
task.State = "Done";
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue" }, ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
public async Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
task.Priority = task.Priority.ToLowerInvariant() switch
|
||||
{
|
||||
"high" => "Medium",
|
||||
"medium" => "Low",
|
||||
"low" => "High",
|
||||
_ => "Medium"
|
||||
};
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}" }, ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class TeamService(IAgentService agentService) : ITeamService
|
||||
{
|
||||
public async Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default)
|
||||
{
|
||||
var agents = await agentService.GetAgentsAsync(ct);
|
||||
var team = new List<TeamMember>(agents.Count);
|
||||
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
var identity = await ReadIdentityAsync(agent.Workspace, ct);
|
||||
team.Add(new TeamMember(
|
||||
agent.Id, agent.Name, agent.Role, agent.Model,
|
||||
agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
|
||||
identity));
|
||||
}
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
private static async Task<string> ReadIdentityAsync(string? workspace, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(workspace) || !Directory.Exists(workspace))
|
||||
return string.Empty;
|
||||
|
||||
var identityFile = Path.Combine(workspace, "IDENTITY.md");
|
||||
if (!File.Exists(identityFile))
|
||||
return string.Empty;
|
||||
|
||||
var content = await File.ReadAllTextAsync(identityFile, ct);
|
||||
return string.Join("\n", content.Split('\n').Where(l => l.StartsWith("- **")).Take(8));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user