using ClawdDotNet.Core.Deploymentcenter;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Licensing;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager;
/// Die Ablage des Managers als Ort für den Lizenzschlüssel (E-M3).
public sealed class ManagerLicenseStore(ManagerSettingsStore store) : ILicenseStore
{
public string ServerUrl => store.Settings.DeploymentcenterUrl;
public string LicenseKey
{
get => store.Settings.LicenseKey;
set => store.Settings.LicenseKey = value;
}
public void Save() => store.Save();
}
/// Warum eine Instanz nicht startet.
public enum StartRefusal
{
/// Sie startet.
None,
/// Keine gültige Lizenz. Eine Lizenz gilt der Installation, nicht der Instanz.
NoLicense,
/// Der Prozess kam nicht hoch oder hat sich nicht gemeldet.
DidNotStart
}
///
/// Der Instanzmanager als Ganzes — ohne Fenster, ohne Weboberfläche, ohne Prozess.
///
/// Was er zusammenhält: die eigenen Einstellungen, die Lizenz, das Register
/// der Instanzen und den Weg, auf dem er stellvertretend meldet. Alles darüber — ein
/// Fenster, die eine Weboberfläche für alle (E-M5) — setzt hier auf und gehört nicht
/// hierher.
///
/// Die Lizenz ist die eine Sperre, die er kennt (E-M3). Sie gilt der
/// Installation und erlaubt beliebig viele Instanzen; ohne sie startet er keine. Und ein
/// Widerruf ist kein Ausfall, sondern ein Urteil: Dann hält er die laufenden an — als
/// einziger Fall, in dem er das von sich aus tut.
///
/// Er hält am Laufen, was ihm anvertraut ist. Eine Instanz mit
/// autostartByManager startet er beim Hochkommen und nach einem Absturz wieder —
/// begrenzt durch , denn was an einer kaputten Konfiguration
/// scheitert, scheitert beim zehnten Versuch genauso. Ohne den Schalter rührt er nichts
/// an: Ein Manager, der nach einem Neustart der Maschine alles hochfährt, was er findet,
/// startet auch das, was jemand mit Absicht angehalten hat.
///
/// Was er nicht tut: Instanzen beim eigenen Ende beenden (E-M6: wer den
/// Manager schließt, will den Manager schließen).
///
public sealed class ManagerCore : IAsyncDisposable
{
private readonly ManagerSettingsStore _settings;
private readonly IInstanceTokenSource _tokens;
private readonly SupervisionReporter? _reporter;
private readonly LicenseGate? _license;
private readonly LicenseWatch? _watch;
private readonly RestartLedger _restarts;
private readonly ILogger _logger;
///
/// Was die letzte Lizenzprüfung ergeben hat.
///
/// Ohne Torwächter true: Ein Manager ohne hinterlegte Lizenzprüfung
/// sperrt nichts — sonst gäbe es eine Henne-Ei-Sperre, bevor überhaupt jemand einen
/// Schlüssel eintragen kann.
///
public bool LicenseHolds { get; private set; } = true;
public ManagerCore(
ManagerSettingsStore settings,
InstanceRegistry instances,
IInstanceTokenSource tokens,
ILoggerFactory loggerFactory,
SupervisionReporter? reporter = null,
LicenseGate? license = null,
LicenseWatch? watch = null,
RestartPolicy? restartPolicy = null,
TimeProvider? clock = null)
{
_settings = settings;
Instances = instances;
_tokens = tokens;
_reporter = reporter;
_license = license;
_watch = watch;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Manager");
_restarts = new RestartLedger(restartPolicy ?? RestartPolicy.Default, clock);
// Jedes Vorkommnis geht ins Protokoll und, wenn es niemand bestellt hat, nach
// draussen. Bewusst nicht abgewartet: Eine Aufsicht darf nicht auf einen Server
// warten muessen, um weiter aufzupassen.
Instances.Observed += ereignis =>
{
if (_reporter is not null)
_ = _reporter.ReportAsync(ereignis);
// Nur beim Absturz, und das schliesst die verstummte Instanz ein: Die
// Aufsicht beendet sie nach ihrer Frist und meldet das als Absturz - denn
// gewollt hat es niemand. Ein zweiter Einstieg bei "Unresponsive" waere ein
// zweiter Neustart fuer denselben Vorfall.
if (ereignis.Kind == InstanceEventKind.Crashed)
_ = RestartAfterCrashAsync(ereignis);
};
if (_watch is not null)
_watch.Revoked += HandleLicenseRevokedAsync;
}
public InstanceRegistry Instances { get; }
public ManagerSettings Settings => _settings.Settings;
// ─── Hochfahren ───
///
/// Prüft die Lizenz, sieht nach, was schon läuft, und übernimmt es.
///
/// In dieser Reihenfolge. Erst die Lizenz, weil sie die Sperre ist. Dann
/// übernehmen, nicht starten — was läuft, lief schon, und was aus war, bleibt aus,
/// bis jemand es verlangt.
///
/// false, wenn die Lizenz die Arbeit verbietet.
public async Task StartAsync(CancellationToken ct = default)
{
if (_license is not null)
{
LicenseHolds = await _license.RunStartupCheckAsync(ct).ConfigureAwait(false);
if (!LicenseHolds)
{
_logger.LogWarning(
"Ohne gültige Lizenz startet der Instanzmanager keine Instanz.");
// Trotzdem nachsehen: Laufende Instanzen gehoeren in die Liste, damit
// jemand sie sieht - und damit sie sich anhalten lassen.
await Instances.RefreshAsync(ct).ConfigureAwait(false);
return false;
}
}
_watch?.Start();
await Instances.RefreshAsync(ct).ConfigureAwait(false);
var laufen = Instances.Instances.Count(i => i.Condition == InstanceCondition.Running);
_logger.LogInformation(
"Instanzmanager bereit: {Gesamt} Instanzen bekannt, {Laufend} laufen bereits.",
Instances.Instances.Count, laufen);
await StartAutostartInstancesAsync(ct).ConfigureAwait(false);
return true;
}
///
/// Startet, was der Manager am Laufen halten soll und was gerade nicht läuft.
///
/// Nach dem Übernehmen, nicht davor. Was schon läuft, wird nicht noch
/// einmal gestartet — es lief schon, und ein zweiter Prozess auf derselben
/// state.db wäre das Gegenteil von hilfreich.
///
public async Task StartAutostartInstancesAsync(CancellationToken ct = default)
{
var offen = Instances.Instances
.Where(i => i.AutostartByManager && i.Condition != InstanceCondition.Running)
.ToList();
if (offen.Count == 0) return;
_logger.LogInformation(
"{Anzahl} Instanzen sind auf Autostart eingestellt und laufen nicht.", offen.Count);
foreach (var instanz in offen)
{
// Der Zaehler gehoert dem Absturz, nicht dem Hochfahren: Ein frischer Manager
// faengt bei null an.
_restarts.Forget(instanz.Directory);
var ergebnis = await StartInstanceAsync(instanz.Directory, ct).ConfigureAwait(false);
if (ergebnis != StartRefusal.None)
{
_logger.LogWarning(
"Autostart von {Name} nicht möglich: {Grund}", instanz.Name, ergebnis);
}
}
}
///
/// Startet eine abgestürzte Instanz wieder — wenn sie darauf eingestellt ist und die
/// Grenze es zulässt.
///
/// Der Sicherungsautomat ist der eigentliche Inhalt. Wieder hochzufahren
/// ist die einfache Hälfte; die schwierige ist zu erkennen, wann man es lassen muss.
/// Nach Versuchen im Zeitfenster setzt der
/// Manager aus und meldet es — die Instanz bleibt dann aus, bis jemand sie von Hand
/// startet.
///
/// Nur bei einem Absturz, nicht wenn eine Instanz verstummt ist. Eine
/// stumme Instanz lebt noch; sie neu zu starten hieße, sie erst abzuschießen, und das
/// ist eine eigene Entscheidung — sie steht als offene Frage im Konzept.
///
/// Öffentlich, damit sich der Weg prüfen lässt, ohne einen echten Absturz
/// herbeizuführen.
///
public async Task RestartAfterCrashAsync(SupervisionEvent ereignis)
{
var instanz = Instances.Instances.FirstOrDefault(
i => ClawdDotNet.Core.Storage.PathBoundary.Comparer.Equals(
i.Directory, ereignis.InstanceDirectory));
if (instanz is not { AutostartByManager: true })
return;
if (!_restarts.TryRecord(instanz.Directory, out var versuche))
{
_logger.LogError(
"Instanz {Name} ist nach {Versuche} Neustarts innerhalb von {Fenster} erneut "
+ "abgestürzt. Der Manager setzt aus — sie bleibt aus, bis jemand sie startet.",
instanz.Name, versuche, _restarts.Policy.Window);
return;
}
_logger.LogWarning(
"Instanz {Name} wird nach einem Absturz wieder gestartet (Versuch {Nummer} von "
+ "{Grenze} innerhalb von {Fenster}).",
instanz.Name, versuche, _restarts.Policy.MaxRestarts, _restarts.Policy.Window);
try
{
await StartInstanceAsync(instanz.Directory).ConfigureAwait(false);
}
catch (Exception ex)
{
// Diese Methode laeuft ohne Aufrufer, der etwas auffangen koennte - sie haengt
// an einem Ereignis. Was hier durchrutscht, waere sonst eine unbeobachtete
// Ausnahme, und die nimmt im Zweifel den Manager mit.
_logger.LogError(ex, "Neustart von {Name} fehlgeschlagen.", instanz.Name);
}
}
// ─── Instanzen ───
///
/// Legt eine Instanz an und besorgt ihr gleich ihr eigenes Melde-Token.
///
/// Beim Anlegen und nicht beim ersten Start, weil die Instanzdatei dann
/// zweifelsfrei niemandem gehört. Sie später zu schreiben, während der Instanzprozess
/// läuft, hieße zwei Schreiber auf einer Datei.
///
public async Task CreateInstanceAsync(string name, CancellationToken ct = default)
{
var verzeichnis = Instances.Create(name);
await _tokens.EnsureTokenAsync(verzeichnis, ct).ConfigureAwait(false);
return verzeichnis;
}
///
/// Startet eine Instanz — wenn die Lizenz es erlaubt.
///
/// Das Token wird vorher besorgt, falls noch keines da ist: bei einer Instanz,
/// die es vor dem Manager schon gab, oder wenn das Deploymentcenter beim Anlegen
/// nicht erreichbar war. Danach ist die Datei dem Instanzprozess überlassen.
///
public async Task StartInstanceAsync(
string instanceDirectory, CancellationToken ct = default)
{
if (!LicenseHolds)
{
_logger.LogWarning(
"Start von {Pfad} abgelehnt: keine gültige Lizenz.", instanceDirectory);
return StartRefusal.NoLicense;
}
await _tokens.EnsureTokenAsync(instanceDirectory, ct).ConfigureAwait(false);
return await Instances.StartAsync(instanceDirectory, ct).ConfigureAwait(false)
? StartRefusal.None
: StartRefusal.DidNotStart;
}
public Task StopInstanceAsync(string instanceDirectory, CancellationToken ct = default)
=> Instances.StopAsync(instanceDirectory, ct);
///
public Task FocusInstanceAsync(string instanceDirectory, CancellationToken ct = default)
=> Instances.FocusAsync(instanceDirectory, ct);
// ─── Das Urteil ───
///
/// Die Lizenz gilt nicht mehr.
///
/// Der einzige Fall, in dem der Manager von sich aus Instanzen anhält. Ein
/// Serverausfall gehört ausdrücklich nicht dazu — das SDK trennt beides, und nur ein
/// Urteil kommt hier an. Sonst nähme ein Netzproblem sämtliche Installationen
/// gleichzeitig mit.
///
/// Öffentlich, weil sich das so prüfen lässt, ohne einen Lizenzserver zu
/// betreiben: Der Weg vom Widerruf zum Anhalten ist die Zusage, nicht die Frage, wer
/// ihn ausgelöst hat.
///
public async Task HandleLicenseRevokedAsync(string grund)
{
LicenseHolds = false;
_logger.LogError("Lizenz gilt nicht mehr: {Grund}", grund);
await Instances.StopAllAsync($"Lizenz gilt nicht mehr: {grund}").ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
if (_watch is not null)
{
_watch.Revoked -= HandleLicenseRevokedAsync;
await _watch.DisposeAsync().ConfigureAwait(false);
}
// Die Instanzen laufen weiter (E-M6).
await Instances.DisposeAsync().ConfigureAwait(false);
}
}