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