- Engine: leere Antworten nachfassen, Aufgaben enden mit Ergebniszeile (EmptyResponse/Incomplete), gleiche Fehlschlaege erkennen (RepeatedFailure), Zeitzeile vor Chatnachrichten, Werkzeugergebnisse gerahmt, Vorpruefung vor dem Staging, Belegkosten wie RunUsage - Staging: gescheiterte Freigaben als Failed; AgentEditor-Aenderungen freigabepflichtig, Selbstbearbeitung gesperrt (allowSelfEdit) - Taskboard: Termine beim Anlegen pruefen, naechster/letzter Lauf, pausieren, Sofortlauf, Kostenprognose, Hinweis auf wachsenden Kontext; Jobs-Reiter und /api/jobs entfernt - Release: Instanzmanager samt Weboberflaeche im Paket, setup.json fragt manager.json ab, Token nicht mehr ins systemd-Journal Version 0.1.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
276 lines
10 KiB
C#
276 lines
10 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);
|
||
|
||
/// <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)
|
||
{
|
||
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);
|
||
|
||
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<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);
|
||
|
||
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));
|
||
}
|
||
|
||
/// <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;
|
||
|
||
var aufgaben = await board.ListAsync(new TaskQuery { Limit = 500 }, 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;
|
||
}
|