Aufgabentyp meeting mit Teilnehmerliste (Frontmatter, SQLite, Taskboard-Werkzeug, WebUI). MeetingRunner: jeder Teilnehmer einmal in Reihenfolge, Beitraege gerahmt als Daten des Verfassers, danach schliesst die Leitung mit Ergebnis und Aufgaben. Protokoll schreibt die Instanz in den Bereich der Einheit der Leitung. Teilnehmer nur aus dem Adressbuch der Leitung, hoechstens 12. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
360 lines
15 KiB
C#
360 lines
15 KiB
C#
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;
|
||
|
||
/// <summary>Eine Aufgabe, wie das Taskboard sie zeigt: Definition, naechster und letzter Lauf.</summary>
|
||
public sealed record TaskOverview(TaskItem Task, DateTime? NextRunUtc, RunReceipt? LastRun, string? Hinweis);
|
||
|
||
/// <summary>Was eine Aufgabe ausmacht - beim Anlegen wie beim Aendern.</summary>
|
||
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<string>? Participants = null);
|
||
|
||
/// <summary>
|
||
/// Das Taskboard fuer die Oberflaechen (#594): anlegen, aendern, pausieren, sofort ausfuehren,
|
||
/// dazu naechster und letzter Lauf und die Kostenprognose (#595).
|
||
///
|
||
/// <para>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.</para>
|
||
///
|
||
/// <para>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.</para>
|
||
/// </summary>
|
||
public sealed class TaskAdministration(
|
||
TaskboardService? board,
|
||
IAuditRepository? audit,
|
||
TaskScanner? scanner,
|
||
IUsageRepository? usage,
|
||
ModelPricingCatalog? pricing,
|
||
Func<IReadOnlyList<AgentConfig>> agents,
|
||
ILogger logger,
|
||
TimeProvider? clock = null,
|
||
Func<Core.Organisation.OrgChart>? 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<IReadOnlyList<TaskOverview>?> 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<TaskOverview>(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<AdminResult> 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<AdminResult> 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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// #717: Fill work is one-time, and its deadline lies ahead — a recurring schedule and a
|
||
/// deadline contradict each other.
|
||
/// </summary>
|
||
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<string> Participants(TaskInput input)
|
||
=> (input.Participants ?? []).Select(p => p.Trim()).Where(p => p.Length > 0).ToList();
|
||
|
||
/// <summary>
|
||
/// #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.
|
||
/// </summary>
|
||
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());
|
||
}
|
||
|
||
/// <summary>#714: The parent must exist and must not lie below the task itself.</summary>
|
||
private async Task<string?> 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;
|
||
}
|
||
|
||
/// <summary>Pausieren heisst <c>backlog</c>, Aktivieren <c>todo</c> - dieselben Zustaende, die der Scanner kennt.</summary>
|
||
public async Task<AdminResult> 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.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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".
|
||
/// </summary>
|
||
public async Task<AdminResult> 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“.");
|
||
}
|
||
|
||
/// <summary>Die Kostenprognose der Instanz (#595); <c>null</c> ohne Taskboard.</summary>
|
||
public async Task<InstanzPrognose?> 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<string, IReadOnlyList<RunReceipt>>(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<RunUsage> 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:<agent>'.";
|
||
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<AgentConfig> 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;
|
||
}
|