Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag. Oberflaeche - Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml (eigene ControlThemes statt Fluent umzufaerben). - Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite statt eigenem Fenster, Texteditor-Fenster. - ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt. Rocket.Chat - Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird nur, wenn wirklich etwas anliegt. - send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist. - Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei Annahmen waren falsch und sind korrigiert. Deploymentcenter - DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json) erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke. - AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
219 lines
8.1 KiB
C#
219 lines
8.1 KiB
C#
using Microsoft.Extensions.Logging;
|
||
|
||
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||
|
||
/// <summary>
|
||
/// Sendet im festen Takt Heartbeats an das Watchdog-Modul und meldet Start und Ende.
|
||
/// Ein nicht erreichbares Deploymentcenter darf ClawdDotNet nie beeinträchtigen —
|
||
/// alle Sendefehler werden geloggt und verschluckt.
|
||
///
|
||
/// <para><b>Sauberes Beenden.</b> Beim Herunterfahren geht ein Heartbeat mit
|
||
/// <c>status: "stopped"</c> raus. Der Evaluator lässt einen so gemeldeten Monitor in
|
||
/// Ruhe; ohne das erzeugte jedes geplante Beenden wenige Minuten später einen
|
||
/// Fehlalarm. Das reine Ereignis genügt dafür nicht — der Evaluator sieht nur den
|
||
/// Monitor-Zustand.</para>
|
||
/// </summary>
|
||
public sealed class WatchdogHeartbeatService : IAsyncDisposable
|
||
{
|
||
private readonly IWatchdogClient _client;
|
||
private readonly bool _ownsClient;
|
||
private readonly IInstanceHealthProvider _health;
|
||
private readonly int _intervalSeconds;
|
||
private readonly ILogger _logger;
|
||
|
||
private CancellationTokenSource? _cts;
|
||
private Task? _loop;
|
||
private string _lastState = "unknown";
|
||
|
||
private string _shutdownStatus = WatchdogStatus.Stopped;
|
||
private string _shutdownMessage = "Instanz planmäßig beendet.";
|
||
private string _shutdownEventKind = WatchdogEventKind.StoppedGraceful;
|
||
|
||
public WatchdogHeartbeatService(
|
||
IWatchdogClient client,
|
||
IInstanceHealthProvider health,
|
||
int intervalSeconds,
|
||
ILogger logger,
|
||
bool ownsClient = false)
|
||
{
|
||
_client = client;
|
||
_health = health;
|
||
_intervalSeconds = Math.Clamp(intervalSeconds, 10, 86400);
|
||
_logger = logger;
|
||
_ownsClient = ownsClient;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Baut Client und Dienst in einem Zug. Wirft nur bei grob falscher Konfiguration
|
||
/// (fehlende oder nicht-HTTPS-URL).
|
||
/// </summary>
|
||
public static WatchdogHeartbeatService Create(
|
||
string baseUrl, string token, string source, string instance, string group, string os,
|
||
string version, int intervalSeconds, IInstanceHealthProvider health, ILogger logger)
|
||
{
|
||
var client = WatchdogClient.Create(baseUrl, token, source, instance, group, os, version);
|
||
return new WatchdogHeartbeatService(
|
||
client, health, intervalSeconds, logger, ownsClient: true);
|
||
}
|
||
|
||
public bool IsRunning => _loop is { IsCompleted: false };
|
||
|
||
/// <summary>Der zuletzt vom Server gemeldete Monitor-Zustand — für die Anzeige.</summary>
|
||
public string LastState => _lastState;
|
||
|
||
/// <summary>
|
||
/// Meldet das nächste Herunterfahren als <b>Wartung</b> statt als planmäßiges Ende.
|
||
///
|
||
/// <para>Gedacht für das Einspielen eines Updates: Die Instanz ist gleich weg,
|
||
/// kommt aber wieder. <c>stopped</c> wäre die falsche Auskunft — es heißt „bewusst
|
||
/// beendet" und lässt den Monitor liegen, bis jemand ihn wieder anfasst.
|
||
/// <c>maintenance</c> sagt dasselbe über den Alarm aus, trägt aber die Absicht
|
||
/// mit: Im Dashboard ist zu sehen, dass hier gerade aktualisiert wird, statt dass
|
||
/// eine Instanz ohne Grund verschwindet.</para>
|
||
/// </summary>
|
||
public void AnnounceMaintenance(string message)
|
||
{
|
||
_shutdownStatus = WatchdogStatus.Maintenance;
|
||
_shutdownMessage = message;
|
||
_shutdownEventKind = WatchdogEventKind.MaintenanceStart;
|
||
}
|
||
|
||
public void Start()
|
||
{
|
||
if (IsRunning)
|
||
return;
|
||
|
||
_cts = new CancellationTokenSource();
|
||
_loop = RunAsync(_cts.Token);
|
||
_logger.LogInformation("Watchdog-Heartbeat gestartet (alle {Interval}s).", _intervalSeconds);
|
||
}
|
||
|
||
private async Task RunAsync(CancellationToken ct)
|
||
{
|
||
await TrySendAsync(
|
||
() => _client.SendEventAsync(
|
||
WatchdogEventKind.Started, "info", "Instanz gestartet.", null, ct),
|
||
"Start-Ereignis").ConfigureAwait(false);
|
||
|
||
// Erster Beat sofort, damit ein neuer Monitor nicht erst nach einem vollen
|
||
// Intervall im Dashboard auftaucht.
|
||
await BeatAsync(ct).ConfigureAwait(false);
|
||
|
||
try
|
||
{
|
||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_intervalSeconds));
|
||
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
|
||
await BeatAsync(ct).ConfigureAwait(false);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Regulärer Stopp.
|
||
}
|
||
}
|
||
|
||
private async Task BeatAsync(CancellationToken ct)
|
||
{
|
||
InstanceHealth health;
|
||
try
|
||
{
|
||
health = await _health.GetAsync(ct).ConfigureAwait(false);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Selbst wenn die Zustandsermittlung scheitert, soll ein Lebenszeichen
|
||
// rausgehen — sonst sieht ein Fehler in unserem Code aus wie ein Ausfall.
|
||
_logger.LogWarning(ex, "Watchdog: Zustandsermittlung fehlgeschlagen – melde warning.");
|
||
health = new InstanceHealth(
|
||
WatchdogStatus.Warning, "Zustand konnte nicht ermittelt werden.",
|
||
new Dictionary<string, double>(), new Dictionary<string, HealthCheck>());
|
||
}
|
||
|
||
await TrySendAsync(async () =>
|
||
{
|
||
var result = await _client.SendHeartbeatAsync(health, _intervalSeconds, ct)
|
||
.ConfigureAwait(false);
|
||
|
||
if (result.State != _lastState)
|
||
{
|
||
_logger.LogInformation("Watchdog: Monitor-Zustand {Previous} → {State}{Failing}",
|
||
_lastState, result.State,
|
||
result.FailingChecks.Count > 0
|
||
? $" (fehlgeschlagen: {string.Join(", ", result.FailingChecks)})"
|
||
: "");
|
||
|
||
_lastState = result.State;
|
||
}
|
||
}, "Heartbeat").ConfigureAwait(false);
|
||
}
|
||
|
||
private async Task TrySendAsync(Func<Task> send, string what)
|
||
{
|
||
try
|
||
{
|
||
await send().ConfigureAwait(false);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (DeploymentcenterException ex) when (ex.IsAuthorizationProblem)
|
||
{
|
||
// Ein abgelehntes Token ist kein Rauschen: Ohne Eingriff bleibt der Monitor
|
||
// für immer stumm, und niemand merkt es, weil ja nichts abstürzt.
|
||
_logger.LogWarning(
|
||
"Watchdog: {What} abgelehnt ({Code}) – Token prüfen (Recht watchdog:ping).",
|
||
what, ex.Code);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Ausfall des Monitorings darf den Betrieb nie stören.
|
||
_logger.LogDebug(ex, "Watchdog: {What} konnte nicht gesendet werden (ignoriert).", what);
|
||
}
|
||
}
|
||
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
if (_cts is null)
|
||
return;
|
||
|
||
await _cts.CancelAsync().ConfigureAwait(false);
|
||
|
||
if (_loop is not null)
|
||
{
|
||
try { await _loop.ConfigureAwait(false); }
|
||
catch (OperationCanceledException) { /* erwartet */ }
|
||
catch (Exception ex) { _logger.LogDebug(ex, "Watchdog: Heartbeat-Schleife endete mit Fehler."); }
|
||
}
|
||
|
||
// Angekündigtes Ende, mit kurzer Frist. Hier wird alles geschluckt (auch ein
|
||
// Zeitüberlauf), damit das Herunterfahren nie hängt oder wirft.
|
||
try
|
||
{
|
||
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||
|
||
await _client.SendHeartbeatAsync(
|
||
new InstanceHealth(
|
||
_shutdownStatus, _shutdownMessage,
|
||
new Dictionary<string, double>(), new Dictionary<string, HealthCheck>()),
|
||
_intervalSeconds, stopCts.Token).ConfigureAwait(false);
|
||
|
||
await _client.SendEventAsync(
|
||
_shutdownEventKind, "info", _shutdownMessage, null, stopCts.Token)
|
||
.ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogDebug(ex, "Watchdog: Ende konnte nicht gemeldet werden (ignoriert).");
|
||
}
|
||
|
||
_cts.Dispose();
|
||
|
||
if (_ownsClient && _client is IDisposable disposable)
|
||
disposable.Dispose();
|
||
}
|
||
}
|