Weboberflaeche hinzugefuegt und Fachlogik nach App herausgeloest (#493, #494)

- 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.
This commit is contained in:
Richard
2026-09-07 21:56:02 +02:00
parent cffd6f4b77
commit 1e9cb789c8
64 changed files with 10543 additions and 554 deletions
@@ -0,0 +1,148 @@
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);
}