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));
}