Files
ClawdDotNet/src/ClawdDotNet.ManagerApp/Program.cs
T
RichardandClaude Opus 5 0b6ea73ec1 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>
2026-09-15 11:23:10 +02:00

325 lines
14 KiB
C#

using System.Runtime.InteropServices;
using ClawdDotNet.Core.Deploymentcenter;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Core.Logging;
using ClawdDotNet.Licensing;
using ClawdDotNet.Manager;
using ClawdDotNet.Manager.Web;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.ManagerApp;
/// <summary>
/// Der Instanzmanager als Prozess (#528, Stufe 4).
///
/// <para><b>Ein Prozess je Maschine.</b> Er startet Instanzen, beaufsichtigt sie, meldet
/// stellvertretend, was sie nicht mehr melden können, und trägt die eine Weboberfläche für
/// alle. Er hält keine Datenbank, führt keine Werkzeuge aus und kennt keine Fachlogik —
/// das alles läuft in den Instanzprozessen.</para>
///
/// <para><b>Und er beendet beim Herunterfahren keine Instanz</b> (E-M6). Wer den Manager
/// schließt, will den Manager schließen. Läuft er wieder an, findet er die laufenden über
/// ihren Socket wieder. Das ist der Grund, warum er nicht der Einzelpunkt ist, gegen den
/// dieses Vorhaben gebaut wurde.</para>
/// </summary>
internal static class Program
{
private const int ExitOk = 0;
private const int ExitNoWebUi = 2;
/// <summary>Wo <c>manager.json</c> liegt, wenn nicht am üblichen Ort.</summary>
private const string ConfigArgument = "--config";
public static async Task<int> Main(string[] args)
{
var settings = new ManagerSettingsStore(ReadConfigDirectory(args));
settings.Load();
var logDirectory = Path.Combine(
ClawdDotNet.Core.Storage.AppPaths.DataDirectory, "Logs", "Manager");
using var loggerFactory = LoggingExtensions.CreateClawdLoggerFactory(
logDirectory, ClawdDotNet.Core.Logging.LogLevel.Info);
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager");
logger.LogInformation("Instanzmanager startet.");
logger.LogInformation("Einstellungen: {Pfad}", settings.SettingsPath);
if (settings.LoadProblem is { } problem)
{
logger.LogError(
"Die Einstellungen unter {Pfad} liessen sich nicht lesen ({Grund}). "
+ "Der Manager arbeitet mit Vorgaben — Instanzverzeichnis, Port und Lizenz "
+ "stehen damit anders, als in der Datei steht.",
settings.SettingsPath, problem);
}
logger.LogInformation("Protokoll: {Pfad}", logDirectory);
logger.LogInformation("Instanzen: {Pfad}", settings.Settings.InstancesDirectory);
// Was tatsaechlich gilt, nicht was in der Datei steht: Beim Abnahmelauf hat eine
// halbe Stunde gefehlt, weil beides auseinanderlief und nichts es sagte.
logger.LogInformation(
"Weboberfläche: Port {Port}, Bindung {Bindung}, Token {Token}. "
+ "Instanz-Programmdatei: {Exe}",
settings.Settings.WebUiPort,
settings.Settings.WebUiBinding,
string.IsNullOrWhiteSpace(settings.Settings.WebUiToken)
? "fehlt"
: $"{settings.Settings.WebUiToken.Length} Zeichen",
string.IsNullOrWhiteSpace(settings.Settings.InstanceExecutable)
? "(neben dem Manager)"
: settings.Settings.InstanceExecutable);
logger.LogInformation(
"Instanzen laufen {Art}. Neustart nach Absturz: hoechstens {Grenze} in {Fenster}.",
settings.Settings.InstanceWindows ? "mit eigenem Fenster" : "ohne Fenster",
settings.Settings.ToRestartPolicy().MaxRestarts,
settings.Settings.ToRestartPolicy().Window);
await using var manager = Build(settings, loggerFactory, logger);
if (!await manager.StartAsync())
logger.LogWarning("Der Manager läuft ohne gültige Lizenz — er startet keine Instanz.");
// Die Sicherungen aller Instanzen - verschluesselt mit dem Lizenzschluessel, auf
// Wunsch zusaetzlich per FTP hinaus (#530). Nach dem Manager, weil sie dessen
// Register braucht, und vor der Weboberflaeche, weil die sie bedient.
var sicherungsLog = loggerFactory.CreateLogger("ClawdDotNet.Manager.Sicherung");
await using var backups = new ManagerBackups(
settings, manager.Instances,
new FtpBackupUploader(() => settings.Settings.BackupFtp, sicherungsLog),
sicherungsLog);
if (backups.LicenseKey is null)
{
logger.LogWarning(
"Im Manager ist kein Lizenzschlüssel hinterlegt: Sicherungen lassen sich nicht "
+ "verschlüsseln und werden deshalb nicht angelegt — auch die täglichen nicht.");
}
backups.Start();
await using var web = ManagerWebHost.TryStart(manager, backups, loggerFactory);
if (web is null)
{
// Ohne Weboberflaeche hat dieser Prozess keinen Zugang: Ein Fenster hat er
// noch nicht (Stufe 5), und ohne Zugang laesst sich nichts starten und nichts
// ansehen. Dann ist Beenden ehrlicher als Weiterlaufen.
logger.LogError(
"Kein Zugang zum Manager: Die Weboberfläche ist nicht hochgekommen. "
+ "Ohne sie lässt sich nichts bedienen — der Manager beendet sich.");
return ExitNoWebUi;
}
Console.WriteLine();
Console.WriteLine("=================================================");
Console.WriteLine(" ClawdDotNet Instanzmanager bereit");
Console.WriteLine($" Weboberfläche: {web.Url}");
if (string.IsNullOrWhiteSpace(settings.Settings.WebUiToken))
{
Console.WriteLine(" Token: (kein Token erforderlich)");
}
else if (Console.IsOutputRedirected)
{
// #588: Unter systemd geht die Standardausgabe ins Journal - dauerhaft und fuer
// die Gruppen adm/systemd-journal lesbar. Dort gehoert der Generalschluessel
// nicht hin. Wer ihn braucht, startet den Manager einmal auf einer Konsole.
Console.WriteLine(" Token: (nur auf einer interaktiven Konsole angezeigt)");
}
else
{
Console.WriteLine($" Token: {settings.Settings.WebUiToken}");
}
Console.WriteLine("=================================================");
Console.WriteLine();
// Das Token steht auf der Konsole, aber ausdruecklich NICHT im Protokoll: Es ist
// der Generalschluessel zu allen Instanzen, und die Protokolldatei ist genau die,
// die man an einen Fehlerbericht haengt. Vor der Konsole steht, wer den Manager
// gestartet hat; die Datei liest hinterher irgendwer.
logger.LogInformation("Instanzmanager bereit. Weboberfläche: {Url}", web.Url);
await WaitForShutdownAsync(logger);
logger.LogInformation(
"Instanzmanager wird beendet. Laufende Instanzen laufen weiter.");
return ExitOk;
}
private static ManagerCore Build(
ManagerSettingsStore settings, ILoggerFactory loggerFactory, ILogger logger)
{
var s = settings.Settings;
var directories = new InstanceDirectoryManager(
Path.GetFullPath(s.InstancesDirectory));
var launcher = new ProcessInstanceLauncher(
ResolveInstanceExecutable(s, logger), headless: !s.InstanceWindows);
var channel = new SocketInstanceChannel();
var registry = new InstanceRegistry(
directories, launcher, channel, loggerFactory, s.ToSupervisionOptions());
// ─── Deploymentcenter: Sub-Token und stellvertretende Meldung ───
IInstanceTokenSource tokens = new NoInstanceTokenSource();
SupervisionReporter? reporter = null;
if (!string.IsNullOrWhiteSpace(s.DeploymentcenterMasterToken))
{
var api = new DeploymentcenterApi(s.DeploymentcenterUrl, s.DeploymentcenterMasterToken);
tokens = new DeploymentcenterTokenSource(
directories, api, loggerFactory.CreateLogger("ClawdDotNet.Manager.Token"));
reporter = new SupervisionReporter(
new BugtrackerClient(api, LicenseInfo.ProductSlug, "production", Version()),
loggerFactory.CreateLogger("ClawdDotNet.Manager.Meldung"));
}
else
{
logger.LogInformation(
"Kein Deploymentcenter-Token hinterlegt: keine Sub-Token, keine "
+ "stellvertretenden Meldungen. Befunde stehen im Protokoll.");
}
// ─── Lizenz (E-M3) ───
LicenseGate? gate = null;
LicenseWatch? watch = null;
if (!string.IsNullOrWhiteSpace(s.LicenseKey) || !string.IsNullOrWhiteSpace(s.DeploymentcenterUrl))
{
gate = new LicenseGate(
new ManagerLicenseStore(settings),
loggerFactory.CreateLogger("ClawdDotNet.Manager.Lizenz"),
LicensePrompt(logger),
Version());
watch = new LicenseWatch(gate, loggerFactory.CreateLogger("ClawdDotNet.Manager.Lizenz"));
}
return new ManagerCore(
settings, registry, tokens, loggerFactory, reporter, gate, watch,
s.ToRestartPolicy());
}
/// <summary>
/// Das Verzeichnis hinter <c>--config</c>, oder <c>null</c> für den üblichen Ort.
///
/// <para><b>Wozu, wenn es <c>CLAWD_CONFIG_DIR</c> gibt.</b> Weil eine Umgebungsvariable
/// an Kindprozesse vererbt wird und ein Argument nicht. Der Manager startet Instanzen;
/// mit der Variablen läse jede von ihnen ihre Anwendungseinstellungen aus dem
/// Verzeichnis des Managers — nachgemessen, sie legt sich dort eine eigene
/// <c>Settings.json</c> an. Ein Argument sagt nur diesem einen Prozess, wo seine Datei
/// liegt, und genau das ist gemeint.</para>
///
/// <para>Für eine systemd-Unit ist es ohnehin der geradere Weg als eine
/// <c>Environment=</c>-Zeile.</para>
/// </summary>
private static string? ReadConfigDirectory(string[] args)
{
for (var i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], ConfigArgument, StringComparison.OrdinalIgnoreCase))
return args[i + 1];
}
return null;
}
/// <summary>
/// Wie der Manager nach einem Lizenzschlüssel fragt: <b>gar nicht.</b>
///
/// <para>Er ist ein Hintergrundprozess. Er wird von einem Dienst gestartet, von einem
/// Skript oder von einem Fenster, das es noch nicht gibt (Stufe 5) — in keinem dieser
/// Fälle sitzt jemand an seiner Konsole. <c>Console.ReadLine</c> steht dort für immer,
/// ohne dass jemand die Frage je zu sehen bekommt, und weil der Manager die Instanzen
/// startet, steht damit die ganze Maschine, nicht bloss eine Anwendung.</para>
///
/// <para><b>Genau so ist der erste Abnahmelauf ausgegangen:</b> Der Prozess hing an
/// „Lizenzschlüssel: ", die Weboberfläche kam nie hoch, und im Protokoll stand nichts
/// davon — die Frage steht auf der Standardausgabe.</para>
///
/// <para><b>Ein Test auf <c>Console.IsInputRedirected</c> reicht nicht.</b> Er
/// beantwortet „ist die Eingabe umgeleitet", nicht „kann jemand antworten": Ein
/// Prozess ganz ohne Konsole — der gefährlichste Fall — ist nicht umgeleitet. Wo die
/// falsche Antwort einen Aufpasser für immer anhält, ist die Frage die falsche Frage.
/// Ein Schlüssel gehört in <c>manager.json</c>, hineingeschrieben von der Einrichtung
/// oder vom Fenster.</para>
/// </summary>
private static ILicensePrompt LicensePrompt(ILogger logger)
=> new NonInteractiveLicensePrompt(text => logger.LogWarning("{Meldung}", text));
/// <summary>
/// Wo die Programmdatei der Instanz liegt.
///
/// <para>Ohne Eintrag: neben der des Managers. Das ist der Normalfall — beide kommen
/// aus demselben Paket —, und ein Eintrag ist die Ausnahme für eine Entwicklungsablage,
/// in der die beiden auseinanderliegen.</para>
/// </summary>
private static string ResolveInstanceExecutable(ManagerSettings settings, ILogger logger)
{
if (!string.IsNullOrWhiteSpace(settings.InstanceExecutable))
return Path.GetFullPath(settings.InstanceExecutable);
var name = OperatingSystem.IsWindows() ? "ClawdDotNet.exe" : "ClawdDotNet";
var daneben = Path.Combine(AppContext.BaseDirectory, name);
if (!File.Exists(daneben))
{
// Nicht abbrechen: Der Manager kann trotzdem beaufsichtigen, was schon laeuft,
// und die Weboberflaeche zeigt es an. Nur starten kann er dann nichts - und
// das soll man wissen, bevor man es versucht.
logger.LogWarning(
"Die Programmdatei der Instanzen liegt nicht unter {Pfad}. Ohne sie lässt "
+ "sich keine Instanz starten (instanceExecutable in manager.json).", daneben);
}
return daneben;
}
private static string Version()
=> typeof(Program).Assembly.GetName().Version?.ToString() ?? "0.0.0";
/// <summary>
/// Wartet, bis jemand Schluss sagt.
///
/// <para><b>Das Signal wird abgefangen, nicht durchgelassen</b>
/// (<c>context.Cancel = true</c>). Sonst räumte die Laufzeit den Prozess ab, während
/// das geordnete Herunterfahren noch läuft — und genau dabei wird der Schreibpuffer des
/// Protokolls geleert. Ein Ende ohne diese Zeilen ist der Zustand aus #529, nur diesmal
/// beim Aufpasser.</para>
/// </summary>
private static async Task WaitForShutdownAsync(ILogger logger)
{
var schluss = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void Beenden(string grund)
{
if (schluss.TrySetResult())
logger.LogInformation("Beendigung angefordert ({Grund}).", grund);
}
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
{
context.Cancel = true;
Beenden("SIGTERM");
});
using var sigint = PosixSignalRegistration.Create(PosixSignal.SIGINT, context =>
{
context.Cancel = true;
Beenden("SIGINT");
});
AppDomain.CurrentDomain.ProcessExit += (_, _) => Beenden("ProcessExit");
await schluss.Task;
}
}