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,260 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Scheduling;
namespace ClawdDotNet.App.Services;
/// <summary>Die zwei Arten geplanter Läufe.</summary>
public enum JobKind
{
/// <summary>Der Agent wird geweckt und arbeitet seine Aufgaben ab. Höchstens einer je Agent.</summary>
AgentWakeup,
/// <summary>Ein Werkzeug wird periodisch abgefragt. Beliebig viele je Agent.</summary>
ToolJob
}
/// <summary>
/// Ein geplanter Lauf, wie ihn eine Oberfläche in einer Liste zeigt.
///
/// <para>Bewusst mit <see cref="DateTime"/> statt einer fertigen Zeichenkette: Wie ein
/// Zeitpunkt aussieht, entscheidet die Anzeige — in einem Fenster anders als in einem
/// Browser, der die Zeitzone des Betrachters kennt. Was die Fachschicht beisteuert, ist
/// der Zeitpunkt selbst.</para>
/// </summary>
/// <param name="CronValid">
/// Falsch, wenn der Ausdruck nicht lesbar ist. Von „kein Ausdruck hinterlegt" zu
/// unterscheiden — dort ist er wahr und <see cref="NextRunUtc"/> leer.
/// </param>
public sealed record JobSummary(
JobKind Kind,
string JobId,
string AgentId,
string AgentName,
string ToolName,
string Cron,
string Message,
bool RunOnStart,
DateTime? NextRunUtc,
bool CronValid,
bool Enabled);
/// <summary>Was einen geplanten Lauf ausmacht — beim Anlegen wie beim Ändern.</summary>
public sealed record JobDefinition(
string AgentId,
JobKind Kind,
string ToolName = "",
string JobTypeId = "",
string Cron = "",
string TaskMessage = "",
bool RunOnStart = false);
/// <summary>
/// Geplante Läufe anlegen, ändern, entfernen und abschalten.
///
/// <para>Stand bis zum 2026-09-06 in
/// <c>ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs</c>. Die Verschiebung ändert
/// nichts an den Regeln — auch nicht daran, dass ein Agent-Wakeup keinen
/// Ein-/Aus-Schalter hat: <see cref="SchedulerConfig"/> kennt keinen, und einen hier zu
/// erfinden hieße, dass der Scanner ihn erst einmal lesen müsste. Sonst liefe ein
/// „abgeschalteter" Wakeup munter weiter — schlimmer als kein Schalter.</para>
/// </summary>
public sealed class JobAdministration(AppHost host)
{
/// <summary>Alle geplanten Läufe der Instanz, Agent für Agent.</summary>
public IReadOnlyList<JobSummary> List()
{
var jobs = new List<JobSummary>();
foreach (var agent in host.Instance.Agents)
{
if (agent.Scheduler is { } scheduler)
{
var (next, valid) = NextRun(scheduler.Cron);
jobs.Add(new JobSummary(
JobKind.AgentWakeup,
WakeupId(agent.AgentId),
agent.AgentId,
agent.DisplayName,
ToolName: "",
scheduler.Cron,
scheduler.TaskMessage,
scheduler.RunOnStart,
next,
valid,
// Ein Wakeup laeuft, solange der Scanner laeuft — er ist der einzige
// periodische Treiber. Ohne ihn steht er, ohne dass jemand ihn
// abgeschaltet haette.
Enabled: host.Scanner is not null));
}
foreach (var job in agent.ToolJobs)
{
var (next, valid) = NextRun(job.Cron);
jobs.Add(new JobSummary(
JobKind.ToolJob,
job.JobId,
agent.AgentId,
agent.DisplayName,
job.ToolName,
job.Cron,
job.JobTypeId,
job.RunOnStart,
next,
valid,
job.Enabled));
}
}
return jobs;
}
/// <summary>
/// Der nächste fällige Zeitpunkt in UTC.
///
/// <para>Ein leerer Ausdruck ist kein Fehler — er heißt „nie". Ein unlesbarer schon;
/// deshalb die zwei Rückgaben statt eines <c>null</c>, das beides bedeuten müsste.</para>
/// </summary>
public static (DateTime? Next, bool Valid) NextRun(string? cron)
{
if (string.IsNullOrWhiteSpace(cron)) return (null, true);
try
{
return (CronExpression.Parse(cron).GetNextOccurrence(DateTime.UtcNow), true);
}
catch
{
return (null, false);
}
}
/// <summary>
/// Die Kennung eines Agent-Wakeups. Er hat keine eigene — es gibt höchstens einen je
/// Agent —, aber eine Liste, in der eine Zeile ausgewählt werden kann, braucht eine.
/// </summary>
public static string WakeupId(string agentId) => $"agent_{agentId}";
public AdminResult Add(JobDefinition definition)
{
if (FindAgent(definition.AgentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{definition.AgentId}'.");
var (_, valid) = NextRun(definition.Cron);
if (!valid)
return AdminResult.Failure($"Der Zeitplan '{definition.Cron}' ist nicht lesbar.");
if (definition.Kind == JobKind.ToolJob)
{
agent.ToolJobs.Add(new ToolJobConfig
{
ToolName = definition.ToolName,
JobTypeId = definition.JobTypeId,
Cron = definition.Cron,
RunOnStart = definition.RunOnStart,
Enabled = true
});
}
else
{
agent.Scheduler = new SchedulerConfig
{
Cron = definition.Cron,
RunOnStart = definition.RunOnStart,
TaskMessage = definition.TaskMessage
};
}
host.SaveInstance(new InstanceChange(InstanceChangeKind.Jobs, definition.AgentId));
return AdminResult.Success(definition.Kind == JobKind.ToolJob
? $"Tool-Job '{definition.JobTypeId}' für '{agent.DisplayName}' angelegt."
: $"Wakeup für '{agent.DisplayName}' angelegt.");
}
public AdminResult Update(string jobId, JobDefinition definition)
{
if (FindAgent(definition.AgentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{definition.AgentId}'.");
var (_, valid) = NextRun(definition.Cron);
if (!valid)
return AdminResult.Failure($"Der Zeitplan '{definition.Cron}' ist nicht lesbar.");
if (definition.Kind == JobKind.ToolJob)
{
if (agent.ToolJobs.FirstOrDefault(j => j.JobId == jobId) is not { } job)
return AdminResult.Failure($"Kein Tool-Job mit der Kennung '{jobId}'.");
job.ToolName = definition.ToolName;
job.JobTypeId = definition.JobTypeId;
job.Cron = definition.Cron;
job.RunOnStart = definition.RunOnStart;
}
else
{
if (agent.Scheduler is not { } scheduler)
return AdminResult.Failure($"'{agent.DisplayName}' hat keinen Wakeup.");
scheduler.Cron = definition.Cron;
scheduler.TaskMessage = definition.TaskMessage;
scheduler.RunOnStart = definition.RunOnStart;
}
host.SaveInstance(new InstanceChange(InstanceChangeKind.Jobs, definition.AgentId));
return AdminResult.Success("Zeitplan gespeichert.");
}
public AdminResult Remove(string agentId, string jobId, JobKind kind)
{
if (FindAgent(agentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
if (kind == JobKind.ToolJob)
{
if (agent.ToolJobs.RemoveAll(j => j.JobId == jobId) == 0)
return AdminResult.Failure($"Kein Tool-Job mit der Kennung '{jobId}'.");
}
else
{
if (agent.Scheduler is null)
return AdminResult.Failure($"'{agent.DisplayName}' hat keinen Wakeup.");
agent.Scheduler = null;
}
host.SaveInstance(new InstanceChange(InstanceChangeKind.Jobs, agentId));
return AdminResult.Success("Geplanter Lauf entfernt.");
}
/// <summary>
/// Schaltet einen Tool-Job an oder ab, ohne ihn zu entfernen.
///
/// <para>Nur Tool-Jobs. Warum ein Wakeup das nicht kann, steht in der
/// Klassenbeschreibung.</para>
/// </summary>
public AdminResult SetEnabled(string agentId, string jobId, bool enabled)
{
if (FindAgent(agentId) 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(
"Nur Tool-Jobs lassen sich abschalten. "
+ "Ein Agent-Wakeup wird über die Agenten-Seite entfernt.");
job.Enabled = enabled;
host.SaveInstance(new InstanceChange(InstanceChangeKind.Jobs, agentId));
return AdminResult.Success(enabled
? $"Job '{job.JobTypeId}' aktiviert."
: $"Job '{job.JobTypeId}' deaktiviert.");
}
private AgentConfig? FindAgent(string agentId)
=> host.Instance.Agents.FirstOrDefault(
a => a.AgentId.Equals(agentId, StringComparison.OrdinalIgnoreCase));
}