using ClawdDotNet.Core.Accounting; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Scheduling; using ClawdDotNet.Core.Tasks; using Microsoft.Extensions.Logging; namespace ClawdDotNet.App.Services; /// Eine Aufgabe, wie das Taskboard sie zeigt: Definition, naechster und letzter Lauf. public sealed record TaskOverview(TaskItem Task, DateTime? NextRunUtc, RunReceipt? LastRun, string? Hinweis); /// Was eine Aufgabe ausmacht - beim Anlegen wie beim Aendern. public sealed record TaskInput( string Title, string Body = "", string Assignee = "", string? WhenKind = null, string? WhenValue = null, string? TimeZone = null, string Acceptance = "", int Priority = 3); /// /// Das Taskboard fuer die Oberflaechen (#594): anlegen, aendern, pausieren, sofort ausfuehren, /// dazu naechster und letzter Lauf und die Kostenprognose (#595). /// /// Hier und nicht im Endpunkt, weil es Regeln sind: Ein Termin, der nie feuern kann, /// wird nicht gespeichert (#597); eine wiederkehrende Aufgabe im Chatverlauf bekommt einen /// Hinweis (#598); nur bereite Aufgaben lassen sich sofort ausfuehren. /// /// Ersetzt fuer die Weboberflaeche den Reiter "Geplante Laeufe", der die /// Alt-Konfiguration scheduler/toolJobs bearbeitete - nach der ersten Uebernahme ins /// Taskboard wirkte dort keine Aenderung mehr. /// public sealed class TaskAdministration( TaskboardService? board, IAuditRepository? audit, TaskScanner? scanner, IUsageRepository? usage, ModelPricingCatalog? pricing, Func> agents, ILogger logger, TimeProvider? clock = null) { private readonly TimeProvider _clock = clock ?? TimeProvider.System; private const string KeinBoard = "Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich."; private DateTime JetztUtc => _clock.GetUtcNow().UtcDateTime; public async Task?> ListAsync(int limit, CancellationToken ct) { if (board is null) return null; var items = await board.ListAsync(new TaskQuery { Limit = Math.Clamp(limit, 1, 500) }, ct); var jetzt = JetztUtc; var result = new List(items.Count); foreach (var task in items) { RunReceipt? letzter = null; if (audit is not null) letzter = (await audit.ListReceiptsForTaskAsync(task.Id, ct)).LastOrDefault(); var laeuft = task.Status is TaskItemStatus.Todo or TaskItemStatus.InProgress && TaskAssignee.KindOf(task.Assignee) != TaskAssigneeKind.Human; result.Add(new TaskOverview( task, laeuft ? TaskSchedule.NextOccurrenceUtc(task, jetzt) : null, letzter, TaskHinweise.WachsenderKontext(task))); } return result; } public async Task CreateAsync(TaskInput input, CancellationToken ct) { if (board is null) return AdminResult.Failure(KeinBoard); if (Pruefe(input, out var assignee, out var when) is { } fehler) return AdminResult.Failure(fehler); var created = await board.CreateAsync(new TaskItem { Title = input.Title.Trim(), Body = input.Body.Trim(), Acceptance = input.Acceptance.Trim(), Priority = Math.Clamp(input.Priority, 1, 5), Assignee = assignee, When = when, Status = TaskItemStatus.Todo, Type = TaskItemType.Work }, ct); return AdminResult.Success(MitHinweis($"Aufgabe '{created.Title}' angelegt ({created.Id}).", created)); } public async Task UpdateAsync(string id, TaskInput input, CancellationToken ct) { if (board is null) return AdminResult.Failure(KeinBoard); if (Pruefe(input, out var assignee, out var when) is { } fehler) return AdminResult.Failure(fehler); var updated = await board.UpdateAsync(id, current => current with { Title = input.Title.Trim(), Body = input.Body.Trim(), Acceptance = input.Acceptance.Trim(), Priority = Math.Clamp(input.Priority, 1, 5), Assignee = assignee, When = when }, ct); return updated is null ? AdminResult.Failure($"Keine Aufgabe '{id}'.") : AdminResult.Success(MitHinweis($"Aufgabe '{updated.Title}' gespeichert.", updated)); } /// Pausieren heisst backlog, Aktivieren todo - dieselben Zustaende, die der Scanner kennt. public async Task SetEnabledAsync(string id, bool enabled, CancellationToken ct) { if (board is null) return AdminResult.Failure(KeinBoard); if (await board.GetAsync(id, ct) is not { } current) return AdminResult.Failure($"Keine Aufgabe '{id}'."); if (current.Status == TaskItemStatus.InProgress) return AdminResult.Failure("Die Aufgabe läuft gerade. Nach dem Lauf erneut versuchen."); var ziel = enabled ? TaskItemStatus.Todo : TaskItemStatus.Backlog; if (current.Status == ziel) return AdminResult.Success(enabled ? "Die Aufgabe ist bereits aktiv." : "Die Aufgabe ist bereits pausiert."); await board.UpdateAsync(id, c => c with { Status = ziel }, ct); return AdminResult.Success(enabled ? $"Aufgabe '{current.Title}' aktiviert." : $"Aufgabe '{current.Title}' pausiert."); } /// /// Stoesst einen Lauf an, ohne auf ihn zu warten - ein Lauf dauert Minuten, eine /// HTTP-Anfrage soll das nicht. Das Ergebnis erscheint unter "Letzter Lauf". /// public async Task RunNowAsync(string id, CancellationToken ct) { if (board is null) return AdminResult.Failure(KeinBoard); if (scanner is null) return AdminResult.Failure("Kein Scanner: In dieser Instanz laufen keine Aufgaben."); if (await board.GetAsync(id, ct) is not { } task) return AdminResult.Failure($"Keine Aufgabe '{id}'."); if (TaskAssignee.KindOf(task.Assignee) == TaskAssigneeKind.Human) return AdminResult.Failure("Diese Aufgabe gehört einem Menschen – dafür gibt es keinen Agentenlauf."); if (task.Status != TaskItemStatus.Todo) return AdminResult.Failure($"Nur bereite Aufgaben lassen sich sofort ausführen (Status: {TaskText.Of(task.Status)})."); _ = Task.Run(async () => { try { if (!await scanner.RunTaskNowAsync(id, CancellationToken.None)) logger.LogInformation("Sofortlauf für Aufgabe {TaskId} fand nicht statt (belegt oder nicht bereit)", id); } catch (Exception ex) { logger.LogError(ex, "Sofortlauf für Aufgabe {TaskId} ist gescheitert", id); } }); return AdminResult.Success($"Lauf für '{task.Title}' angestoßen. Das Ergebnis erscheint unter „Letzter Lauf“."); } /// Die Kostenprognose der Instanz (#595); null ohne Taskboard. public async Task ForecastAsync(CancellationToken ct) { if (board is null) return null; var aufgaben = await board.ListAsync(new TaskQuery { Limit = 500 }, ct); var belege = new Dictionary>(StringComparer.Ordinal); if (audit is not null) { foreach (var aufgabe in aufgaben.Where(a => TaskAssignee.KindOf(a.Assignee) != TaskAssigneeKind.Human)) belege[aufgabe.Id] = await audit.ListReceiptsForTaskAsync(aufgabe.Id, ct); } IReadOnlyList verbrauch = usage is null ? [] : await usage.GetRecentAsync(1000, ct); return KostenPrognose.Berechne( aufgaben, belege, verbrauch, agents(), pricing is null ? null : pricing.Estimate, JetztUtc); } // ─── Regeln ─── private string? Pruefe(TaskInput input, out string assignee, out TaskWhen? when) { assignee = input.Assignee?.Trim() ?? ""; when = null; if (string.IsNullOrWhiteSpace(input.Title)) return "Ohne Titel geht es nicht."; if (assignee.Length == 0) return "Wer soll die Aufgabe ausführen? Einen Agenten oder einen Menschen wählen."; if (PruefeZuordnung(assignee) is { } zuordnung) return zuordnung; if (string.IsNullOrWhiteSpace(input.WhenKind)) return null; // einmalig if (TaskText.WhenKind(input.WhenKind) is not { } kind) return "Terminart muss 'cron', 'every' oder 'at' sein."; if (string.IsNullOrWhiteSpace(input.WhenValue)) return "Zum Termin fehlt der Wert (Cron-Ausdruck, Intervall oder Zeitpunkt)."; var candidate = new TaskWhen { Kind = kind, Value = input.WhenValue.Trim(), TimeZone = string.IsNullOrWhiteSpace(input.TimeZone) ? TimeZones.LocalIanaId : input.TimeZone.Trim() }; if (TaskSchedule.ValidateWhen(candidate) is { } termin) return termin; when = candidate; return null; } private string? PruefeZuordnung(string assignee) { var liste = agents(); switch (TaskAssignee.KindOf(assignee)) { case TaskAssigneeKind.Human: return null; case TaskAssigneeKind.New: { var id = TaskAssignee.AgentId(assignee); if (id.Length == 0) return liste.Count == 1 ? null : "'@new' braucht bei mehreren Agenten einen Namen: '@new:'."; return Existiert(liste, id) ? null : $"Kein Agent '{id}'."; } default: { var id = TaskAssignee.AgentId(assignee); return Existiert(liste, id) ? null : $"Kein Agent '{id}'."; } } } private static bool Existiert(IReadOnlyList liste, string agentId) => liste.Any(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase)); private static string MitHinweis(string meldung, TaskItem task) => TaskHinweise.WachsenderKontext(task) is { } hinweis ? $"{meldung} {hinweis}" : meldung; }