Files
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

348 lines
14 KiB
C#

using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class DashboardService(
IOpenClawGatewayClient gateway,
IOpenClawControlService openClaw,
IOpenClawChatService chat,
ITaskService taskService,
ILogger<DashboardService> logger) : IDashboardService
{
public async Task<DashboardStatus> GetStatusAsync()
{
var connection = openClaw.GetConnection();
var tasks = await openClaw.GetTasksAsync(200);
var sessions = await openClaw.GetSessionsAsync(200);
return new DashboardStatus(
connection.Connected,
connection.Connected ? "Online" : "Offline",
sessions.Items.Count(session => session.Status is "running" or "active"),
tasks.Items.Count(task => task.Status is "queued" or "running"));
}
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
{
var agentsTask = openClaw.GetAgentsAsync();
var sessionsTask = openClaw.GetSessionsAsync(500);
var tasksTask = openClaw.GetTasksAsync(500);
await Task.WhenAll(agentsTask, sessionsTask, tasksTask);
var agents = agentsTask.Result;
var sessions = sessionsTask.Result;
var tasks = tasksTask.Result;
return agents.Items.Select(agent =>
{
var session = sessions.Items
.Where(item => string.Equals(item.AgentId, agent.Id, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.UpdatedAt)
.FirstOrDefault();
var task = tasks.Items
.Where(item =>
string.Equals(item.AgentId, agent.Id, StringComparison.OrdinalIgnoreCase) ||
(!string.IsNullOrWhiteSpace(item.SessionKey) &&
string.Equals(item.SessionKey, session?.Key, StringComparison.Ordinal)))
.Where(item => item.Status is "queued" or "running" or "active")
.OrderByDescending(item => item.UpdatedAt ?? item.StartedAt ?? item.CreatedAt)
.FirstOrDefault();
var active = session?.Status is "running" or "active";
return new DashboardAgentInfo(
Id: agent.Id,
Name: agent.Name,
Role: DeriveRole(agent.Id),
Model: session?.Model ?? agent.Model ?? "openclaw/default",
IsActive: active,
CurrentTask: task?.Title ?? (active ? session?.Title : null),
Description: agent.Description,
Tags: BuildAgentTags(agent.Id),
Progress: task?.Progress,
Workload: sessions.Items.Count(item =>
string.Equals(item.AgentId, agent.Id, StringComparison.OrdinalIgnoreCase) &&
item.Status is "running" or "active"),
Goal: null,
RoleBadge: DeriveRoleBadge(agent.Id),
StatusLabel: active ? "Working" : "Ready",
StatusKind: active ? "working" : "ready",
StatusDetail: session is null ? "No Gateway session reported." : session.Title,
Elapsed: null,
Think: null,
Next: null,
TotalTokens: session?.TotalTokens,
CostUsd: null,
TelemetryAt: session?.UpdatedAt);
}).ToList();
}
public async Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter)
{
var activity = await openClaw.GetActivityAsync(Math.Clamp(limit, 1, 100));
var entries = activity.Items;
if (!string.IsNullOrWhiteSpace(agentFilter))
entries = entries
.Where(item => string.Equals(item.AgentId, agentFilter, StringComparison.OrdinalIgnoreCase))
.ToArray();
return entries.Select(item => new FeedEntry(
item.AgentId ?? item.Actor ?? "OpenClaw",
item.Message,
item.OccurredAt?.ToString("O") ?? activity.CheckedAt.ToString("O"),
item.OccurredAt?.ToLocalTime().ToString("HH:mm") ?? "--:--",
item.AgentId,
item.EventType)).ToList();
}
public async Task<ChatResponse> SendChatAsync(string agentId, string message)
{
try
{
var context = OpenClawInvocationContextFactory.Create(
actor: "nexus-dashboard",
idempotencyKey: null,
correlationId: null,
traceParent: null);
var result = await chat.SendAsync(
message,
$"nexus-dashboard-{agentId.ToLowerInvariant()}",
agentId,
new OpenClawInvocationMetadata(
context.IdempotencyKey,
context.CorrelationId,
context.Actor,
context.TraceParent),
CancellationToken.None);
return new ChatResponse(true, result.Content, null);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard chat send failed");
return new ChatResponse(false, null, "OpenClaw chat endpoint is not enabled or reachable.");
}
}
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 = openClaw.GetCronJobsAsync(200, ct);
var tasksTask = taskService.GetOpenAsync(ct);
await Task.WhenAll(cronTask, tasksTask);
var merged = cronTask.Result.Items.Select(job => new QueueItem(
job.Id,
job.Name,
job.Status,
"medium",
"cron",
FormatWaitTime(job.NextRunAt))).ToList();
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<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct)
{
await Task.CompletedTask;
var connection = openClaw.GetConnection();
var versionStatus = !connection.Connected
? "error"
: !connection.VersionPinned
? "unpinned"
: connection.GatewayVersion is null
? "missing"
: connection.VersionMatches ? "matched" : "drift";
return new GatewayRuntimeInfo(
connection.Connected,
connection.Endpoint,
connection.GatewayVersion,
connection.RequiredVersion,
connection.VersionPinned,
connection.VersionMatches,
versionStatus,
connection.CheckedAt,
connection.Message,
connection.Recovery);
}
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
{
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
return new QueueDeleteResult(QueueDeleteOutcome.Ignored);
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)
};
}
return new QueueDeleteResult(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)
{
var sessions = await openClaw.GetSessionsAsync(500);
var session = sessions.Items
.Where(item => string.Equals(item.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.UpdatedAt)
.FirstOrDefault();
return session?.Model is null
? null
: new AgentModelInfo(session.Model, session.Provider ?? "unknown");
}
public async Task<bool> SetAgentModelAsync(string agentId, string model)
{
var result = await openClaw.PatchSessionModelAsync(
$"agent:{agentId}:main",
model);
return result.Ok;
}
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit)
{
var response = await openClaw.GetActivityAsync(Math.Clamp(limit * 5, 1, 100));
return response.Items
.Where(item => string.Equals(item.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
.Take(Math.Clamp(limit, 1, 20))
.Select(item => new AgentActivityEntry(
FormatTimeAgo(item.OccurredAt),
item.Message,
item.OccurredAt ?? response.CheckedAt,
item.Source))
.ToList();
}
public async Task<List<ModelOption>> GetAvailableModelsAsync(CancellationToken ct)
{
var models = await openClaw.GetModelsAsync(ct);
return models.Items
.Where(model => !string.IsNullOrWhiteSpace(model.Id))
.Select(model => new ModelOption(
model.Id,
string.IsNullOrWhiteSpace(model.Name) ? model.Id : model.Name,
model.Provider))
.DistinctBy(model => model.Id, StringComparer.OrdinalIgnoreCase)
.OrderBy(model => model.Provider, StringComparer.OrdinalIgnoreCase)
.ThenBy(model => model.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
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
};
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "Orchestrator",
"programmer" => "Developer",
"reviewer" => "Reviewer",
"architekt" => "Architect",
"researcher" => "Researcher",
"executor" => "Executor",
"main" => "Assistant",
_ => "Agent"
};
private static string DeriveRoleBadge(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "badge-violet",
"reviewer" => "badge-amber",
"executor" => "badge-green",
_ => "badge-blue"
};
private static string[] BuildAgentTags(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => ["orchestration", "delegation", "approvals"],
"programmer" => ["code", "build", "test"],
"reviewer" => ["review", "quality", "security"],
"architekt" => ["architecture", "infrastructure"],
"researcher" => ["research", "analysis"],
"executor" => ["execution", "operations"],
_ => ["openclaw"]
};
private static string FormatWaitTime(DateTimeOffset? nextRunAt)
{
if (nextRunAt is null)
return "--";
var remaining = nextRunAt.Value - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero) return "now";
if (remaining.TotalMinutes < 1) return "<1m";
if (remaining.TotalHours < 1) return $"{(int)remaining.TotalMinutes}m";
if (remaining.TotalDays < 1) return $"{(int)remaining.TotalHours}h";
return $"{(int)remaining.TotalDays}d";
}
private static string FormatTimeAgo(DateTimeOffset? timestamp)
{
if (timestamp is null)
return "unknown";
var elapsed = DateTimeOffset.UtcNow - timestamp.Value;
if (elapsed.TotalMinutes < 1) return "now";
if (elapsed.TotalHours < 1) return $"{(int)elapsed.TotalMinutes}m";
if (elapsed.TotalDays < 1) return $"{(int)elapsed.TotalHours}h";
return $"{(int)elapsed.TotalDays}d";
}
}