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