using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Scheduling; namespace ClawdDotNet.App.Services; /// Die zwei Arten geplanter Läufe. public enum JobKind { /// Der Agent wird geweckt und arbeitet seine Aufgaben ab. Höchstens einer je Agent. AgentWakeup, /// Ein Werkzeug wird periodisch abgefragt. Beliebig viele je Agent. ToolJob } /// /// Ein geplanter Lauf, wie ihn eine Oberfläche in einer Liste zeigt. /// /// Bewusst mit 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. /// /// /// Falsch, wenn der Ausdruck nicht lesbar ist. Von „kein Ausdruck hinterlegt" zu /// unterscheiden — dort ist er wahr und leer. /// 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); /// Was einen geplanten Lauf ausmacht — beim Anlegen wie beim Ändern. public sealed record JobDefinition( string AgentId, JobKind Kind, string ToolName = "", string JobTypeId = "", string Cron = "", string TaskMessage = "", bool RunOnStart = false); /// /// Geplante Läufe anlegen, ändern, entfernen und abschalten. /// /// Stand bis zum 2026-09-06 in /// ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs. Die Verschiebung ändert /// nichts an den Regeln — auch nicht daran, dass ein Agent-Wakeup keinen /// Ein-/Aus-Schalter hat: 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. /// public sealed class JobAdministration(AppHost host) { /// Alle geplanten Läufe der Instanz, Agent für Agent. public IReadOnlyList List() { var jobs = new List(); 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; } /// /// Der nächste fällige Zeitpunkt in UTC. /// /// Ein leerer Ausdruck ist kein Fehler — er heißt „nie". Ein unlesbarer schon; /// deshalb die zwei Rückgaben statt eines null, das beides bedeuten müsste. /// 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); } } /// /// 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. /// 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."); } /// /// Schaltet einen Tool-Job an oder ab, ohne ihn zu entfernen. /// /// Nur Tool-Jobs. Warum ein Wakeup das nicht kann, steht in der /// Klassenbeschreibung. /// 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)); }