Entscheidungen aus dem Testbetrieb umgesetzt (#572): #577 #579 #581 #589 #193 #205 #594 #595 #597 #598 #599 #588

- 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>
This commit is contained in:
Richard
2026-09-15 11:23:10 +02:00
co-authored by Claude Opus 5
parent 8ab9d99abe
commit 0b6ea73ec1
42 changed files with 2646 additions and 512 deletions
+141
View File
@@ -83,6 +83,147 @@ public static class TaskSchedule
}
}
/// <summary>
/// Prueft einen Termin, bevor er gespeichert wird - liefert den Grund, warum er nie
/// feuern wuerde, oder <c>null</c> (#597).
///
/// <para>Ein ungueltiger Termin laesst den Scanner nicht abstuerzen, sondern feuert
/// schlicht nie. Beim Anlegen ist der richtige Moment, das zu sagen: Dann sieht es der,
/// der ihn eingetragen hat, statt Stunden spaeter niemand.</para>
/// </summary>
public static string? ValidateWhen(TaskWhen when)
{
if (!string.IsNullOrWhiteSpace(when.TimeZone) && !TimeZones.IsKnown(when.TimeZone))
return $"Die Zeitzone '{when.TimeZone}' ist auf diesem System nicht bekannt (IANA-Schreibweise, z.B. 'Europe/Berlin').";
switch (when.Kind)
{
case TaskWhenKind.Cron:
try
{
CronExpression.Parse(when.Value);
return null;
}
catch (FormatException ex)
{
return ex.Message;
}
case TaskWhenKind.Every:
return TryParseInterval(when.Value, out _)
? null
: $"'{when.Value}' ist kein Intervall (erwartet z.B. 30m, 2h oder 1d).";
case TaskWhenKind.At:
return TryResolveInstant(when.Value, when.TimeZone, out _)
? null
: $"'{when.Value}' ist kein Zeitpunkt (erwartet z.B. 2026-09-15T08:00).";
default:
return "Unbekannte Terminart.";
}
}
/// <summary>
/// Der naechste geplante Lauf in UTC - fuer die Anzeige im Taskboard (#594). <c>null</c>,
/// wenn keiner mehr ansteht oder der Termin nicht lesbar ist.
/// </summary>
public static DateTime? NextOccurrenceUtc(TaskItem task, DateTime nowUtc)
{
nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
if (task.When is not { } when)
return task.LastOccurrence is null ? nowUtc : null;
switch (when.Kind)
{
case TaskWhenKind.At:
return task.LastOccurrence is null && TryResolveInstant(when.Value, when.TimeZone, out var at)
? at
: null;
case TaskWhenKind.Every:
{
if (!TryParseInterval(when.Value, out var interval))
return null;
var anchor = task.LastOccurrence is not null
? ParseIso(task.LastOccurrence)
: (task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime());
var next = anchor + interval;
return next < nowUtc ? nowUtc : next;
}
case TaskWhenKind.Cron:
return TryPrepareCron(when, out var cron, out var tz) ? NextCronUtc(cron, tz, nowUtc) : null;
default:
return null;
}
}
/// <summary>
/// Wie viele Laeufe der Termin im Schnitt je Tag ausloest, gerechnet ueber die naechsten
/// <paramref name="days"/> Tage - fuer die Kostenprognose (#595). Einmalige Termine
/// liefern 0.
/// </summary>
public static double RunsPerDay(TaskItem task, DateTime nowUtc, int days = 7)
{
nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
if (task.When is not { } when || days <= 0)
return 0;
switch (when.Kind)
{
case TaskWhenKind.Every:
return TryParseInterval(when.Value, out var interval) ? TimeSpan.FromDays(1) / interval : 0;
case TaskWhenKind.Cron:
{
if (!TryPrepareCron(when, out var cron, out var tz))
return 0;
var end = nowUtc.AddDays(days);
var count = 0;
var cursor = nowUtc;
// Obergrenze: jede Minute ueber sieben Tage sind 10.080 - mehr gibt es nicht.
while (count < 100_000 && NextCronUtc(cron, tz, cursor) is { } next && next <= end)
{
count++;
cursor = next;
}
return count / (double)days;
}
default:
return 0;
}
}
private static bool TryPrepareCron(TaskWhen when, out CronExpression cron, out TimeZoneInfo tz)
{
cron = null!;
tz = null!;
try { cron = CronExpression.Parse(when.Value); }
catch (FormatException) { return false; }
if (ResolveTimeZone(when.TimeZone) is not { } zone)
return false;
tz = zone;
return true;
}
private static DateTime? NextCronUtc(CronExpression cron, TimeZoneInfo tz, DateTime afterUtc)
{
var local = Truncate(TimeZoneInfo.ConvertTimeFromUtc(afterUtc, tz));
if (cron.GetNextOccurrence(local) is not { } nextLocal)
return null;
return TryToUtc(nextLocal, tz, out var utc) ? utc : null;
}
private static string? DueAt(TaskWhen when, string? marker, DateTime nowUtc)
{
if (marker is not null) return null; // ein einmaliger Termin feuert genau einmal