- 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.
261 lines
9.1 KiB
C#
261 lines
9.1 KiB
C#
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));
|
|
}
|