- ClawdDotNet.Web: Kestrel im Prozess der Anwendung, Token-Absicherung (TokenGate mit FixedTimeEquals), Endpunkte unter /api, Ereignisstrom unter /events (Server-Sent Events) und fortlaufendes Log-Streaming. - Weboberflaeche (wwwroot/): Vollstaendiges Single-Page-Frontend mit nativem HTML5, CSS3 und ES-Modulen ohne Buildschritt (strikt textContent, #-Routing, sofortiger Login-Sprung bei fehlendem Token, Light/Dark-Theme, lokale Barlow-Schriften). - ClawdDotNet.App: Fachlogik aus den Avalonia-ViewModels in Dienste herausgeloest (AgentAdministration, JobAdministration, ServiceAdministration, RunCoordinator) und ueber InstanceChange synchronisiert. - Tests: ClawdDotNet.Web.Tests und ClawdDotNet.App.Tests hinzugefuegt, alle 684 Tests erfolgreich.
149 lines
5.9 KiB
C#
149 lines
5.9 KiB
C#
using System.Collections.Concurrent;
|
|
using ClawdDotNet.App.Models;
|
|
|
|
namespace ClawdDotNet.App.Services;
|
|
|
|
/// <summary>
|
|
/// „Jetzt ausführen" — von Hand angestoßene Läufe, gleich von welcher Oberfläche.
|
|
///
|
|
/// <para>Stand bis zum 2026-09-06 zweimal in den Ansichtsmodellen: einmal für den
|
|
/// Agenten (<c>AgentsPageViewModel.RunAgentNowAsync</c>), einmal für den geplanten Lauf
|
|
/// (<c>TasksPageViewModel.RunJobNowAsync</c>). Beide Fassungen taten fast dasselbe und
|
|
/// unterschieden sich in dem, was sie in den Verlauf schrieben.</para>
|
|
///
|
|
/// <para>Die Unterscheidung, die diese Klasse trägt: <b>ein Tool-Job geht über den
|
|
/// Scanner, ein Agentenlauf über die Engine.</b> Der Scanner ist der einzige periodische
|
|
/// Treiber (A1) und kennt die Sperre auf der Aufgabe — ein Tool-Job an ihm vorbei liefe
|
|
/// womöglich neben dem geplanten Lauf desselben Jobs.</para>
|
|
/// </summary>
|
|
public sealed class RunCoordinator
|
|
{
|
|
private readonly AppHost _host;
|
|
|
|
/// <summary>
|
|
/// Wann ein Lauf zuletzt von Hand angestoßen wurde, je Job-Kennung. Nur im
|
|
/// Arbeitsspeicher und bewusst nicht in der Instanzdatei — es ist eine Notiz für die
|
|
/// Anzeige, keine Konfiguration. Beide Oberflächen teilen sie sich, damit ein im
|
|
/// Browser angestoßener Lauf auch im Fenster als „zuletzt" auftaucht.
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<string, DateTime> _lastRuns = new();
|
|
|
|
public RunCoordinator(AppHost host)
|
|
{
|
|
_host = host;
|
|
History = new JobHistoryService(host.InstancePath);
|
|
}
|
|
|
|
/// <summary>Der Verlauf von Hand angestoßener Läufe. Fasst sich selbst threadsicher an.</summary>
|
|
public JobHistoryService History { get; }
|
|
|
|
public DateTime? LastRun(string jobId)
|
|
=> _lastRuns.TryGetValue(jobId, out var when) ? when : null;
|
|
|
|
/// <summary>
|
|
/// Weckt einen Agenten von Hand.
|
|
///
|
|
/// <para>Ohne eigene Nachricht wird die des geplanten Laufs genommen — der Handlauf
|
|
/// soll zeigen, was der geplante täte.</para>
|
|
/// </summary>
|
|
public async Task<AdminResult> RunAgentAsync(
|
|
string agentId, string? message, CancellationToken ct)
|
|
{
|
|
if (_host.Instance.Agents.FirstOrDefault(
|
|
a => a.AgentId.Equals(agentId, StringComparison.OrdinalIgnoreCase)) is not { } agent)
|
|
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
|
|
|
if (_host.Engine is null)
|
|
return AdminResult.Failure("Agent-Engine ist nicht aktiv (kein API-Key).");
|
|
|
|
var taskMessage = string.IsNullOrWhiteSpace(message)
|
|
? agent.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus."
|
|
: message;
|
|
|
|
try
|
|
{
|
|
var result = await _host.Engine.RunAsync(
|
|
agent, taskMessage, _host.Instance.InstanceId, ct);
|
|
|
|
_lastRuns[JobAdministration.WakeupId(agent.AgentId)] = DateTime.Now;
|
|
|
|
History.Add(new JobHistoryEntry
|
|
{
|
|
JobName = "Agent Wakeup",
|
|
Agent = agent.DisplayName,
|
|
Time = DateTime.Now,
|
|
JobDescription = taskMessage,
|
|
Info = $"{result.Status} ({result.StepCount} Steps, {result.TokensUsed:N0} Tokens)",
|
|
Status = "Manual"
|
|
});
|
|
|
|
return AdminResult.Success(
|
|
$"'{agent.DisplayName}' abgeschlossen: {result.Status} "
|
|
+ $"({result.StepCount} Schritte, {result.TokensUsed:N0} Tokens).");
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return AdminResult.Failure($"Lauf von '{agent.DisplayName}' abgebrochen.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return AdminResult.Failure($"Lauf fehlgeschlagen: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stößt einen Tool-Job von Hand an — über den Scanner, nicht an ihm vorbei.
|
|
///
|
|
/// <para>„Nicht bereit" ist keine Störung: Der Scanner gibt <c>false</c> zurück, wenn
|
|
/// die Aufgabe gerade von jemand anderem gehalten wird oder gar nicht existiert.
|
|
/// Genau dafür ist die Sperre da.</para>
|
|
/// </summary>
|
|
public async Task<AdminResult> RunToolJobAsync(
|
|
string agentId, string jobId, CancellationToken ct)
|
|
{
|
|
if (_host.Instance.Agents.FirstOrDefault(
|
|
a => a.AgentId.Equals(agentId, StringComparison.OrdinalIgnoreCase)) is not { } agent)
|
|
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
|
|
|
if (agent.ToolJobs.FirstOrDefault(j => j.JobId == jobId) is not { } job)
|
|
return AdminResult.Failure($"Kein Tool-Job mit der Kennung '{jobId}'.");
|
|
|
|
if (_host.Scanner is null)
|
|
return AdminResult.Failure("Scanner ist nicht aktiv.");
|
|
|
|
try
|
|
{
|
|
var ran = await _host.Scanner.RunTaskNowAsync($"tj-{agent.AgentId}-{job.JobId}", ct);
|
|
var info = ran ? "Ausgeführt" : "Nicht bereit";
|
|
|
|
_lastRuns[job.JobId] = DateTime.Now;
|
|
|
|
History.Add(new JobHistoryEntry
|
|
{
|
|
JobName = job.JobTypeId,
|
|
Agent = agent.DisplayName,
|
|
Time = DateTime.Now,
|
|
JobDescription = $"Tool Job: {job.ToolName}",
|
|
Info = info,
|
|
Status = "Manual"
|
|
});
|
|
|
|
return AdminResult.Success($"Tool-Job '{job.JobTypeId}' abgeschlossen ({info}).");
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return AdminResult.Failure($"Tool-Job '{job.JobTypeId}' abgebrochen.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return AdminResult.Failure($"Fehler beim Ausführen: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>Stößt an, was hinter der Zeile steckt — Wakeup oder Tool-Job.</summary>
|
|
public Task<AdminResult> RunAsync(JobSummary job, CancellationToken ct)
|
|
=> job.Kind == JobKind.ToolJob
|
|
? RunToolJobAsync(job.AgentId, job.JobId, ct)
|
|
: RunAgentAsync(job.AgentId, job.Message, ct);
|
|
}
|