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, string ParentId = "", // #717: fill work — at the latest by (ISO or yyyy-MM-dd), and the author's estimate per run. string? FillBy = null, decimal? EstimateUsd = null, // #686: a meeting's participants, in the order they speak. Given: the task is a meeting. IReadOnlyList? Participants = null); /// /// 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, Func? organisation = 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); if (await CheckParentAsync(null, input.ParentId, ct) is { } parentProblem) return AdminResult.Failure(parentProblem); if (CheckFill(input, when, out var fillBy) is { } fillProblem) return AdminResult.Failure(fillProblem); if (CheckMeeting(input, assignee) is { } meetingProblem) return AdminResult.Failure(meetingProblem); 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 = IsMeeting(input) ? TaskItemType.Meeting : TaskItemType.Work, Participants = Participants(input), ParentId = input.ParentId.Trim(), FillBy = fillBy, EstimateUsd = input.EstimateUsd }, 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); if (await CheckParentAsync(id, input.ParentId, ct) is { } parentProblem) return AdminResult.Failure(parentProblem); if (CheckFill(input, when, out var fillBy) is { } fillProblem) return AdminResult.Failure(fillProblem); if (CheckMeeting(input, assignee) is { } meetingProblem) return AdminResult.Failure(meetingProblem); var updated = await board.UpdateAsync(id, current => current with { // A work task stays work, a tool job a tool job; only participants make a meeting. Type = IsMeeting(input) ? TaskItemType.Meeting : current.Type == TaskItemType.Meeting ? TaskItemType.Work : current.Type, Participants = Participants(input), Title = input.Title.Trim(), Body = input.Body.Trim(), Acceptance = input.Acceptance.Trim(), Priority = Math.Clamp(input.Priority, 1, 5), Assignee = assignee, When = when, ParentId = input.ParentId.Trim(), FillBy = fillBy, EstimateUsd = input.EstimateUsd }, ct); return updated is null ? AdminResult.Failure($"Keine Aufgabe '{id}'.") : AdminResult.Success(MitHinweis($"Aufgabe '{updated.Title}' gespeichert.", updated)); } /// /// #717: Fill work is one-time, and its deadline lies ahead — a recurring schedule and a /// deadline contradict each other. /// private static string? CheckFill(TaskInput input, TaskWhen? when, out DateTime? fillBy) { fillBy = null; if (input.EstimateUsd is < 0) return "The estimate cannot be negative."; if (string.IsNullOrWhiteSpace(input.FillBy)) return null; if (TaskFrontmatter.ParseUtc(input.FillBy) is not { } parsed) return $"'{input.FillBy}' is not a point in time. Use a date (yyyy-MM-dd) or an ISO timestamp."; if (when is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every }) return "Fill work is one-time: a deadline and a recurring schedule do not go together."; if (parsed <= DateTime.UtcNow) return "The deadline of fill work has to lie ahead."; fillBy = parsed; return null; } private static bool IsMeeting(TaskInput input) => Participants(input).Count > 0; private static IReadOnlyList Participants(TaskInput input) => (input.Participants ?? []).Select(p => p.Trim()).Where(p => p.Length > 0).ToList(); /// /// #686: A meeting has an agent in the chair, and participants the chair can address — /// the same rule as for an agent that schedules one. /// private string? CheckMeeting(TaskInput input, string assignee) { if (!IsMeeting(input)) return null; var chair = TaskAssignee.AgentId(assignee); if (string.IsNullOrWhiteSpace(chair)) return "A meeting needs an agent in the chair."; return MeetingRunner.Refusal(Participants(input), chair, organisation?.Invoke()); } /// #714: The parent must exist and must not lie below the task itself. private async Task CheckParentAsync(string? taskId, string parentId, CancellationToken ct) { parentId = parentId.Trim(); if (parentId.Length == 0 || board is null) return null; if (await board.GetAsync(parentId, ct) is null) return $"There is no task '{parentId}' to serve as parent."; if (taskId is not null && await GoalChain.WouldCycleAsync(taskId, parentId, board.GetAsync, ct)) return $"'{parentId}' lies below '{taskId}' (or is the task itself) — a task cannot serve itself."; return null; } /// 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; // #716: ungekappt - vorher fehlten ab 500 Aufgaben die übrigen in der Prognose. var aufgaben = await board.ListAllAsync(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; }