diff --git a/docs/Instanzmanager-Konzept.md b/docs/Instanzmanager-Konzept.md index dd0db23..0b4bf22 100644 --- a/docs/Instanzmanager-Konzept.md +++ b/docs/Instanzmanager-Konzept.md @@ -323,7 +323,7 @@ Verbindung und führt sie zusammen, mit der Instanzkennung an jedem Ereignis. | 2 ✔ | Instanzprozess kopflos lauffähig, Kestrel auf dem Socket | Eine Instanz läuft ohne Fenster, der Socket antwortet | | 3 ◑ | Manager: anlegen, starten, anhalten, Prozessaufsicht, Lizenz, Sub-Token | Eine Instanz abschießen — der Manager merkt es und meldet es | | 4 ◑ | Weboberfläche zieht um, Weiterleitung, ein Ereignisstrom | Eine Adresse, ein Zugang, Wechsel ohne Neustart | -| 5 | Instanzfenster als eigener Prozess, Wechsel holt das Fenster nach vorn | Kein Neustart mehr beim Wechseln | +| 5 ✔ | Instanzfenster als eigener Prozess, Wechsel holt das Fenster nach vorn | Kein Neustart mehr beim Wechseln | | 6 | Sicherungen im Manager (falls Abschnitt 7 dafür entscheidet) | Zurückspielen hält die Instanz an und startet sie wieder | **Stufe 1 ist die, die man überspringen möchte und nicht überspringen darf.** @@ -611,3 +611,40 @@ spricht `/api/…` an und bekommt die eine laufende. Instanzauswahl, `X-Instanz` Verwaltungsansicht sind Arbeit an `wwwroot` und gehören damit in Geminis Spur. Und `webServerPort` steht noch in der Instanz: Ihr TCP-Zugang ist erst überflüssig, wenn die Oberfläche im Manager vollständig ist. + +### 9.6 Was Stufe 5 gebracht hat + +**Der Wechsel ist kein Neustart mehr.** Vorher hieß „Instanz wechseln": Der laufende +Prozess fährt sich herunter und startet sich mit einer anderen Instanz neu — wer +wechselte, beendete zwangsläufig die Instanz, aus der heraus er es tat, und genau dabei +hat sich die Anwendung am 2026-09-08 aufgehängt. Jetzt laufen alle, und es kommt eine nach +vorn. + +**Die Instanz holt ihr Fenster selbst.** Neu ist `POST /api/instanz/fenster`, wieder +ausschließlich über den Socket. Der Manager kennt kein Fenstersystem und soll keines +kennen; er bittet, und wer ein Fenster hat, holt es nach vorn. Kopflos läuft die Bitte ins +Leere, und das ist die richtige Antwort. **Das letzte Wort hat der Fenstermanager** — +Windows lässt einen Hintergrundprozess den Vordergrund nicht einfach an sich reißen; der +kurze Ausflug über `Topmost` half im Versuch, unter Linux entscheidet der Fenstermanager. +Mehr als bitten kann eine Anwendung nicht. + +**`--managed` schaltet den Wechsel im Instanzfenster ab.** Der Manager gibt es jedem +Instanzprozess mit; das Fenster gräut seinen Instanz-Schalter aus und erklärt auf Nachfrage, +wo gewechselt wird. Zwei Schlösser an derselben Tür: Der Knopf ist aus, und der Weg +dahinter prüft es noch einmal — hinter ihm wartet ein Selbst-Neustart. + +**`instanceWindows` in `manager.json`**, vorbelegt auf `true`: Jede Instanz behält ihr +Fenster (E-M2). Auf einer Maschine ohne Bildschirmsitzung gehört `false` hin. + +**Abnahme, an echten Fenstern gemessen:** + +| Geprüft | Ergebnis | +|---|---| +| Instanz über den Manager gestartet | PID 18128, Fenstertitel „ClawdDotNet" | +| Befehlszeile | `--managed --instance …` | +| Fenster von außen minimiert | `IsIconic = True` | +| `POST /instanzen/Instance-Test/fenster` | „Fenster von 'Test' nach vorn geholt" | +| Danach | `IsIconic = False`, **im Vordergrund** | +| Prozess danach | **PID 18128, unverändert** — nichts neu gestartet | + +Die letzte Zeile ist die Abnahmebedingung: kein Neustart mehr beim Wechseln. diff --git a/src/ClawdDotNet.Desktop/App.axaml.cs b/src/ClawdDotNet.Desktop/App.axaml.cs index 4d362ef..3748d60 100644 --- a/src/ClawdDotNet.Desktop/App.axaml.cs +++ b/src/ClawdDotNet.Desktop/App.axaml.cs @@ -32,6 +32,16 @@ public partial class App : Application /// Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht. private string? _preselectedInstancePath; + /// + /// Vom Instanzmanager gestartet (--managed). + /// + /// Dann gehoert der Instanzwechsel dorthin: Der Manager haelt die Fenster aller + /// Instanzen im Hintergrund und holt das gewuenschte nach vorn (E-M2). Ein Fenster, + /// das sich selbst mit einer anderen Instanz neu startet, naehme genau die Instanz + /// mit, aus der heraus jemand wechselt. + /// + private bool _managed; + public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void OnFrameworkInitializationCompleted() @@ -44,6 +54,7 @@ public partial class App : Application desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; _preselectedInstancePath = CommandLine.ReadInstancePath(desktop.Args); + _managed = CommandLine.IsManaged(desktop.Args); ApplySavedThemeEarly(); @@ -127,7 +138,11 @@ public partial class App : Application // Instanzverzeichnis, ueber den der Instanzmanager die Instanz erreicht (#528). _web = InstanceHttpHost.TryStart(_host, _host.LoggerFactory); - var mainViewModel = new MainWindowViewModel(_host); + var mainViewModel = new MainWindowViewModel(_host) + { + // Wer beaufsichtigt wird, wechselt nicht selbst - siehe _managed. + CanSwitchInstance = !_managed + }; // Der Wechsel gehoert hierher und nicht ins Fenster: Er braucht den Aufbau und // die Lebensdauer der Anwendung, und beides kennt nur diese Klasse. @@ -135,6 +150,10 @@ public partial class App : Application desktop.MainWindow = new MainWindow { DataContext = mainViewModel }; + // Der Manager bittet ueber den Socket, dieses Fenster nach vorn zu holen (E-M2). + if (_web is not null) + _web.Control.FocusRequested += () => BringToFront(desktop); + desktop.MainWindow.Show(); desktop.MainWindow.Closed += (_, _) => desktop.Shutdown(); @@ -311,6 +330,38 @@ public partial class App : Application return await completion.Task; }); + /// + /// Holt das Fenster dieser Instanz nach vorn — auf Bitte des Instanzmanagers. + /// + /// Die Instanz macht das selbst, statt dass jemand von aussen an ihrem + /// Fenster zieht. Der Manager kennt kein Fenstersystem und soll keines kennen; er + /// bittet über den Socket, und wer ein Fenster hat, holt es nach vorn. Kopflos läuft + /// die Bitte ins Leere, und das ist die richtige Antwort. + /// + /// Das letzte Wort hat der Fenstermanager. Windows lässt einen Prozess + /// im Hintergrund den Vordergrund nicht einfach an sich reissen — Activate + /// blinkt dann nur in der Leiste. Der kurze Ausflug über Topmost bringt es in + /// den meisten Fällen trotzdem nach vorn; unter Linux entscheidet der Fenstermanager, + /// und manche sind da streng. Mehr als bitten kann eine Anwendung nicht, und mehr + /// sollte sie auch nicht tun. + /// + private static void BringToFront(IClassicDesktopStyleApplicationLifetime desktop) + => Dispatcher.UIThread.Post(() => + { + if (desktop.MainWindow is not { } fenster) return; + + if (fenster.WindowState == WindowState.Minimized) + fenster.WindowState = WindowState.Normal; + + fenster.Show(); + + var vorher = fenster.Topmost; + fenster.Topmost = true; + fenster.Topmost = vorher; + + fenster.Activate(); + }); + /// /// Instanzwechsel aus der laufenden Anwendung. /// @@ -326,6 +377,19 @@ public partial class App : Application { if (_host is null) return; + if (_managed) + { + // Sollte gar nicht erst aufrufbar sein (CanSwitchInstance), aber ein zweites + // Schloss an der Tuer, hinter der ein Selbst-Neustart wartet, ist billig. + await new AvaloniaLicensePrompt().ShowInfoAsync( + "ClawdDotNet – Instanzwechsel", + "Diese Instanz wird vom Instanzmanager geführt. Gewechselt wird dort — " + + "er holt das Fenster der gewünschten Instanz nach vorn, statt eine " + + "laufende zu beenden."); + + return; + } + var viewModel = new InstancePickerViewModel(_host.Directories, _host.InstancePath); var window = new InstancePickerWindow { DataContext = viewModel }; diff --git a/src/ClawdDotNet.Desktop/CommandLine.cs b/src/ClawdDotNet.Desktop/CommandLine.cs index 32cb68e..ddc7dd3 100644 --- a/src/ClawdDotNet.Desktop/CommandLine.cs +++ b/src/ClawdDotNet.Desktop/CommandLine.cs @@ -16,6 +16,17 @@ internal static class CommandLine /// Läuft ohne Fenster: nur Aufbau, Socket und Warten (#528, Stufe 2). public const string HeadlessArgument = "--headless"; + /// + /// Gestartet und beaufsichtigt vom Instanzmanager (#528, Stufe 5). + /// + /// Dann gehört der Instanzwechsel dorthin und nicht in dieses Fenster: Der + /// Manager hält die Fenster aller Instanzen im Hintergrund und holt das gewünschte + /// nach vorn. Ein Fenster, das sich selbst mit einer anderen Instanz neu startet, + /// nähme dabei genau die Instanz mit, aus der heraus jemand wechselt — und das ist + /// der Vorfall, mit dem dieses Vorhaben angefangen hat (#529). + /// + public const string ManagedArgument = "--managed"; + /// /// Der Instanzpfad hinter --instance, oder null. /// @@ -37,6 +48,13 @@ internal static class CommandLine } public static bool IsHeadless(IReadOnlyList? args) + => Has(args, HeadlessArgument); + + /// + public static bool IsManaged(IReadOnlyList? args) + => Has(args, ManagedArgument); + + private static bool Has(IReadOnlyList? args, string flag) => args is not null - && args.Any(a => string.Equals(a, HeadlessArgument, StringComparison.OrdinalIgnoreCase)); + && args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase)); } diff --git a/src/ClawdDotNet.Desktop/HeadlessRun.cs b/src/ClawdDotNet.Desktop/HeadlessRun.cs index 8eac663..c4f5ae1 100644 --- a/src/ClawdDotNet.Desktop/HeadlessRun.cs +++ b/src/ClawdDotNet.Desktop/HeadlessRun.cs @@ -90,7 +90,7 @@ internal static class HeadlessRun "Instanz {Name} läuft ohne Fenster auf {Endpunkt}", host.Instance.InstanceName, web.Endpoint); - await WaitForShutdownAsync(logger, web.ShutdownSignal); + await WaitForShutdownAsync(logger, web.Control); logger.LogInformation("Instanz {Name} wird beendet.", host.Instance.InstanceName); } @@ -111,7 +111,7 @@ internal static class HeadlessRun /// 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. /// - private static async Task WaitForShutdownAsync(ILogger logger, ShutdownSignal signal) + private static async Task WaitForShutdownAsync(ILogger logger, InstanceControl steuerung) { var schluss = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -124,7 +124,7 @@ internal static class HeadlessRun // Der Instanzmanager, ueber den Socket. Der uebliche Weg, sobald es ihn gibt: // Er bittet, statt zu schiessen - ein Instanzprozess haelt eine offene Datenbank // und laufende Agenten, und beides will zu Ende gebracht werden. - signal.Requested += () => Beenden("Instanzmanager"); + steuerung.ShutdownRequested += () => Beenden("Instanzmanager"); using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => { diff --git a/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs index e7edeac..8dda372 100644 --- a/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs +++ b/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs @@ -97,6 +97,16 @@ public sealed partial class MainWindowViewModel : ViewModelBase public event Action? RequestExit; public event Action? RequestInstanceManagerDialog; + /// + /// Ob der Instanzwechsel aus diesem Fenster heraus angeboten wird. + /// + /// false, sobald der Instanzmanager diese Instanz führt (#528, E-M2): + /// Dann hält er die Fenster aller Instanzen im Hintergrund und holt das gewünschte + /// nach vorn. Ein Wechsel von hier aus wäre ein Selbst-Neustart und nähme genau die + /// Instanz mit, aus der heraus jemand wechselt. + /// + public bool CanSwitchInstance { get; init; } = true; + /// Für den Entwurfsmodus des Editors — ohne laufenden Aufbau. public MainWindowViewModel() : this(null) { } diff --git a/src/ClawdDotNet.Desktop/Views/MainWindow.axaml b/src/ClawdDotNet.Desktop/Views/MainWindow.axaml index 7476aa7..6576eb9 100644 --- a/src/ClawdDotNet.Desktop/Views/MainWindow.axaml +++ b/src/ClawdDotNet.Desktop/Views/MainWindow.axaml @@ -52,9 +52,14 @@ IsVisible="{Binding IsSidebarExpanded}" /> + einziger die ganze Anwendung umschaltet. + + Ausgegraut, sobald der Instanzmanager diese Instanz fuehrt (#528, E-M2): + Gewechselt wird dann dort, und er holt das Fenster der gewuenschten Instanz + nach vorn, statt eine laufende zu beenden. --> Task RequestStopAsync(string instanceDirectory, CancellationToken ct = default); + + /// + /// Bittet die Instanz, ihr Fenster nach vorn zu holen (E-M2). + /// + /// false heißt: Die Bitte kam nicht an, oder diese Instanz hat kein + /// Fenster — sie läuft kopflos. Beides ist kein Fehler, sondern eine Auskunft. + /// + Task RequestFocusAsync(string instanceDirectory, CancellationToken ct = default); } /// @@ -93,8 +101,15 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC } } - public async Task RequestStopAsync( + public Task RequestFocusAsync( string instanceDirectory, CancellationToken ct = default) + => PostAsync(instanceDirectory, "/api/instanz/fenster", ct); + + public Task RequestStopAsync( + string instanceDirectory, CancellationToken ct = default) + => PostAsync(instanceDirectory, "/api/instanz/beenden", ct); + + private async Task PostAsync(string instanceDirectory, string pfad, CancellationToken ct) { try { @@ -102,7 +117,7 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC client.Timeout = _timeout; using var antwort = await client - .PostAsync("/api/instanz/beenden", content: null, ct) + .PostAsync(pfad, content: null, ct) .ConfigureAwait(false); return antwort.IsSuccessStatusCode; diff --git a/src/ClawdDotNet.Manager/InstanceLauncher.cs b/src/ClawdDotNet.Manager/InstanceLauncher.cs index 1d870f9..940892f 100644 --- a/src/ClawdDotNet.Manager/InstanceLauncher.cs +++ b/src/ClawdDotNet.Manager/InstanceLauncher.cs @@ -7,7 +7,7 @@ namespace ClawdDotNet.Manager; /// /// Bewusst schmal. Die Aufsicht fragt nicht die Prozessliste, ob eine Instanz /// lebt — das beantwortet der Socket, und nur er beantwortet es richtig (siehe -/// ). Vom Prozess braucht sie dreierlei: seine Kennung +/// ). Vom Prozess braucht sie dreierlei: seine Kennung /// fürs Protokoll, den Abschiedscode, wenn er weg ist, und die Möglichkeit, ihn zu /// beenden, wenn er nicht mehr auf Bitten hört. /// @@ -50,11 +50,14 @@ public interface IInstanceLauncher /// /// Der echte Start: ClawdDotNet --headless --instance <Verzeichnis>. /// -/// Vorerst ohne Fenster. Nach E-M2 behält jede Instanz ihr eigenes -/// Desktop-Fenster; das kommt mit Stufe 5, wenn der Wechsel das Fenster nach vorn holt. -/// Bis dahin ist kopflos die Betriebsart, die sich vollständig beobachten lässt — und -/// eine Instanz, die auf einem Server ohne Bildschirmsitzung gestartet wird, braucht sie -/// ohnehin. +/// Mit oder ohne Fenster. Nach E-M2 behält jede Instanz ihr eigenes +/// Desktop-Fenster; sie laufen im Hintergrund, und beim Wechsel kommt eines nach vorn. +/// Auf einer Maschine ohne Bildschirmsitzung gibt es keines, und dann ist kopflos die +/// einzige Betriebsart, die trägt. +/// +/// --managed geht immer mit. Daran erkennt die Instanz, dass sie +/// geführt wird, und bietet den Instanzwechsel nicht mehr selbst an: Der wäre ein +/// Selbst-Neustart und nähme genau die Instanz mit, aus der heraus jemand wechselt. /// public sealed class ProcessInstanceLauncher(string executablePath, bool headless = true) : IInstanceLauncher @@ -85,6 +88,7 @@ public sealed class ProcessInstanceLauncher(string executablePath, bool headless if (headless) start.ArgumentList.Add("--headless"); + start.ArgumentList.Add("--managed"); start.ArgumentList.Add("--instance"); start.ArgumentList.Add(Path.GetFullPath(instanceDirectory)); diff --git a/src/ClawdDotNet.Manager/InstanceRegistry.cs b/src/ClawdDotNet.Manager/InstanceRegistry.cs index 4ff80c1..f184a84 100644 --- a/src/ClawdDotNet.Manager/InstanceRegistry.cs +++ b/src/ClawdDotNet.Manager/InstanceRegistry.cs @@ -201,6 +201,28 @@ public sealed class InstanceRegistry : IAsyncDisposable public Task StopAsync(string instanceDirectory, CancellationToken ct = default) => Find(instanceDirectory).StopAsync(ct); + /// + /// Holt das Fenster einer Instanz nach vorn — der Wechsel, wie E-M2 ihn meint. + /// + /// Das ist der Ersatz für den Selbst-Neustart. Vorher hiess „Instanz + /// wechseln": Der laufende Prozess fährt sich herunter und startet sich mit einer + /// anderen Instanz neu — wer wechselte, beendete zwangsläufig die Instanz, aus der + /// heraus er es tat. Jetzt laufen alle, und es kommt nur eine nach vorn. + /// + /// false heisst: Sie läuft nicht, sie hört nicht, oder sie hat kein + /// Fenster. + /// + public async Task FocusAsync(string instanceDirectory, CancellationToken ct = default) + { + var aufsicht = Find(instanceDirectory); + + if (aufsicht.Condition != InstanceCondition.Running) + return false; + + return await _channel.RequestFocusAsync(aufsicht.InstanceDirectory, ct) + .ConfigureAwait(false); + } + /// /// Hält alle laufenden Instanzen an. Nicht beim Beenden des Managers, sondern /// nur, wenn es einen Grund gibt, der sie alle betrifft — ein Lizenzwiderruf. diff --git a/src/ClawdDotNet.Manager/ManagerCore.cs b/src/ClawdDotNet.Manager/ManagerCore.cs index 28aff16..f3a1850 100644 --- a/src/ClawdDotNet.Manager/ManagerCore.cs +++ b/src/ClawdDotNet.Manager/ManagerCore.cs @@ -189,6 +189,10 @@ public sealed class ManagerCore : IAsyncDisposable 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 ─── /// diff --git a/src/ClawdDotNet.Manager/ManagerSettings.cs b/src/ClawdDotNet.Manager/ManagerSettings.cs index 744088d..f5b94fd 100644 --- a/src/ClawdDotNet.Manager/ManagerSettings.cs +++ b/src/ClawdDotNet.Manager/ManagerSettings.cs @@ -39,6 +39,18 @@ public sealed class ManagerSettings [JsonPropertyName("instanceExecutable")] public string InstanceExecutable { get; set; } = ""; + /// + /// Ob gestartete Instanzen ein eigenes Fenster bekommen (E-M2). + /// + /// Vorbelegt auf true: Jede Instanz behält ihr Desktop-Fenster, sie laufen + /// im Hintergrund, und beim Wechsel kommt eines nach vorn. Auf einer Maschine ohne + /// Bildschirmsitzung — einem Server, einem Bauknecht — gehört hier false hin; + /// dann laufen die Instanzen kopflos und sind nur über die Weboberfläche zu + /// bedienen. + /// + [JsonPropertyName("instanceWindows")] + public bool InstanceWindows { get; set; } = true; + // ─── Die eine Weboberfläche (E-M5) ─── [JsonPropertyName("webUiPort")] diff --git a/src/ClawdDotNet.ManagerApp/Program.cs b/src/ClawdDotNet.ManagerApp/Program.cs index 61a0f38..9f054b6 100644 --- a/src/ClawdDotNet.ManagerApp/Program.cs +++ b/src/ClawdDotNet.ManagerApp/Program.cs @@ -72,6 +72,10 @@ internal static class Program ? "(neben dem Manager)" : settings.Settings.InstanceExecutable); + logger.LogInformation( + "Instanzen laufen {Art}.", + settings.Settings.InstanceWindows ? "mit eigenem Fenster" : "ohne Fenster"); + await using var manager = Build(settings, loggerFactory, logger); if (!await manager.StartAsync()) @@ -110,7 +114,8 @@ internal static class Program var directories = new InstanceDirectoryManager( Path.GetFullPath(s.InstancesDirectory)); - var launcher = new ProcessInstanceLauncher(ResolveInstanceExecutable(s, logger)); + var launcher = new ProcessInstanceLauncher( + ResolveInstanceExecutable(s, logger), headless: !s.InstanceWindows); var channel = new SocketInstanceChannel(); var registry = new InstanceRegistry( diff --git a/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs index 8be8aaf..6dafca1 100644 --- a/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs @@ -6,31 +6,46 @@ using Microsoft.AspNetCore.Routing; namespace ClawdDotNet.Web.Endpoints; /// -/// Der Wunsch, dass dieser Instanzprozess endet. +/// Was der Instanzmanager diesem Prozess sagen kann — und was der Prozess damit macht. /// -/// Ein eigener Typ und kein Rückruf am Endpunkt, weil zwei Seiten ihn brauchen: -/// Der Endpunkt löst ihn aus, der Wirtsprozess wartet darauf. Wer wartet, ist verschieden -/// — kopflos die Schleife in HeadlessRun, mit Fenster die Avalonia-Anwendung. +/// Ein eigener Typ und keine Rückrufe am Endpunkt, weil zwei Seiten ihn brauchen: +/// Der Endpunkt löst aus, der Wirtsprozess reagiert. Und wer reagiert, ist verschieden — +/// kopflos die Warteschleife in HeadlessRun, mit Fenster die +/// Avalonia-Anwendung. /// -public sealed class ShutdownSignal +public sealed class InstanceControl { - /// Wird höchstens einmal ausgelöst. - public event Action? Requested; + private int _shutdownRaised; - private int _raised; + /// Der Manager bittet, diesen Prozess zu beenden. + public event Action? ShutdownRequested; /// - /// Meldet den Wunsch an. Mehrfaches Aufrufen tut nichts — ein zweiter Aufruf würde - /// sonst mitten im Herunterfahren ein zweites Herunterfahren anstoßen. + /// Der Manager bittet, das Fenster dieser Instanz nach vorn zu holen (E-M2). + /// + /// Kopflos gibt es nichts nach vorn zu holen; dann läuft dieses Ereignis ins + /// Leere, und das ist die richtige Antwort. /// - public bool Raise() + public event Action? FocusRequested; + + /// + /// Meldet den Wunsch zu enden an. Ein zweiter Aufruf tut nichts — sonst stiesse er + /// mitten im Herunterfahren ein zweites Herunterfahren an. + /// + public bool RequestShutdown() { - if (Interlocked.Exchange(ref _raised, 1) != 0) + if (Interlocked.Exchange(ref _shutdownRaised, 1) != 0) return false; - Requested?.Invoke(); + ShutdownRequested?.Invoke(); return true; } + + /// + /// Holt das Fenster nach vorn. Beliebig oft — anders als beim Beenden ist die + /// Wiederholung hier der Normalfall: Jeder Wechsel auf diese Instanz ist einer. + /// + public void RequestFocus() => FocusRequested?.Invoke(); } /// @@ -40,8 +55,8 @@ public sealed class ShutdownSignal /// Endpunkte nicht, auch mit gültigem Token nicht, und sie antworten dort mit 404 statt /// mit 403: Was es nicht gibt, muss man auch nicht erklären. Der Grund ist der Schnitt aus /// #528 — die Lebensdauer einer Instanz gehört dem Instanzmanager. Eine Browsersitzung -/// soll den Prozess, der sie gerade bedient, nicht beenden können; der Manager soll es, -/// und er kommt über den Socket. +/// soll den Prozess, der sie gerade bedient, nicht beenden und auch nicht sein Fenster +/// aufreissen können; der Manager soll es, und er kommt über den Socket. /// internal static class ControlEndpoints { @@ -49,7 +64,7 @@ internal static class ControlEndpoints { var control = app.MapGroup("/api/instanz"); - control.MapPost("/beenden", (HttpContext context, ShutdownSignal signal) => + control.MapPost("/beenden", (HttpContext context, InstanceControl steuerung) => { if (!LocalTransport.Applies(context)) return Results.NotFound(); @@ -61,12 +76,25 @@ internal static class ControlEndpoints // haengt aber ab, ob er anschliessend geduldig wartet oder abschiesst. context.Response.OnCompleted(() => { - signal.Raise(); + steuerung.RequestShutdown(); return Task.CompletedTask; }); // 202 und nicht 200: Beim Antworten laeuft der Prozess noch. return Results.Accepted(value: new { angenommen = true }); }); + + control.MapPost("/fenster", (HttpContext context, InstanceControl steuerung) => + { + if (!LocalTransport.Applies(context)) + return Results.NotFound(); + + // Hier ohne OnCompleted: Das Fenster nach vorn zu holen beendet nichts, die + // Antwort geht ohnehin raus. Und der Manager soll gleich erfahren, ob dieser + // Prozess ueberhaupt eines hat. + steuerung.RequestFocus(); + + return Results.Ok(new { angenommen = true }); + }); } } diff --git a/src/ClawdDotNet.Web/InstanceHttpHost.cs b/src/ClawdDotNet.Web/InstanceHttpHost.cs index 7e2e25f..619abe0 100644 --- a/src/ClawdDotNet.Web/InstanceHttpHost.cs +++ b/src/ClawdDotNet.Web/InstanceHttpHost.cs @@ -51,19 +51,20 @@ public sealed class InstanceHttpHost : IAsyncDisposable Endpoint = endpoint; Url = url; - ShutdownSignal = app.Services.GetRequiredService(); + Control = app.Services.GetRequiredService(); } /// - /// Der Instanzmanager hat gebeten, diesen Prozess zu beenden - /// (POST /api/instanz/beenden, nur über den Socket). + /// Was der Instanzmanager diesem Prozess über den Socket sagen kann: sich beenden + /// (POST /api/instanz/beenden) oder sein Fenster nach vorn holen + /// (POST /api/instanz/fenster). /// - /// Der Wirtsprozess hängt sich hier an und beendet sich: kopflos die - /// Warteschleife, mit Fenster die Avalonia-Anwendung. Diese Klasse tut es nicht - /// selbst — sie kennt nur ihren Kestrel und wüsste gar nicht, was sonst noch zu - /// beenden wäre. + /// Der Wirtsprozess hängt sich hier an und tut es: kopflos beendet sich die + /// Warteschleife, mit Fenster reagiert die Avalonia-Anwendung. Diese Klasse tut es + /// nicht selbst — sie kennt nur ihren Kestrel und wüsste gar nicht, was sonst noch zu + /// beenden oder anzuzeigen wäre. /// - public ShutdownSignal ShutdownSignal { get; } + public InstanceControl Control { get; } /// Socket oder Named Pipe — der Weg, den der Instanzmanager nimmt. public string Endpoint { get; } @@ -184,7 +185,7 @@ public sealed class InstanceHttpHost : IAsyncDisposable builder.WebHost.ConfigureKestrel(kestrel => Listen(kestrel, host.InstancePath, options)); builder.Services.AddSingleton(host); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); if (options is not null) { diff --git a/tests/ClawdDotNet.Manager.Tests/Doubles.cs b/tests/ClawdDotNet.Manager.Tests/Doubles.cs index b48d4f2..f881867 100644 --- a/tests/ClawdDotNet.Manager.Tests/Doubles.cs +++ b/tests/ClawdDotNet.Manager.Tests/Doubles.cs @@ -118,4 +118,16 @@ internal sealed class FakeChannel : IInstanceChannel StopRequests++; return Task.FromResult(StopRequestArrives); } + + /// Welche Instanzen gebeten wurden, ihr Fenster nach vorn zu holen. + public List FocusRequests { get; } = []; + + /// Ob die Instanz ein Fenster hat, das nach vorn kommen kann. + public bool HasWindow { get; set; } = true; + + public Task RequestFocusAsync(string instanceDirectory, CancellationToken ct = default) + { + FocusRequests.Add(Path.GetFullPath(instanceDirectory)); + return Task.FromResult(HasWindow); + } } diff --git a/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs index 39247b5..2d1cee2 100644 --- a/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs +++ b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs @@ -217,3 +217,93 @@ public sealed class ManagerCoreTests : IAsyncDisposable (await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart); } } + +/// +/// Der Wechsel, wie E-M2 ihn meint (#528, Stufe 5). +/// +/// Vorher hiess „Instanz wechseln": Der laufende Prozess fährt sich herunter +/// und startet sich mit einer anderen Instanz neu. Wer wechselte, beendete zwangsläufig +/// die Instanz, aus der heraus er es tat — und genau dabei hat sich am 2026-09-08 die +/// Anwendung aufgehängt (#529). Jetzt laufen alle, und es kommt nur eine nach vorn. +/// +public sealed class InstanceFocusTests : IAsyncDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "focus-" + Guid.NewGuid().ToString("N")[..8]); + + private readonly FakeLauncher _launcher = new(); + private readonly FakeChannel _kanal = new(); + private readonly InstanceDirectoryManager _verzeichnisse; + + private static readonly SupervisionOptions Options = new( + ProbeInterval: TimeSpan.FromMilliseconds(50), + StartTimeout: TimeSpan.FromMilliseconds(600), + SilentChecksBeforeAlarm: 3, + StopGrace: TimeSpan.FromMilliseconds(300)); + + public InstanceFocusTests() + { + Directory.CreateDirectory(_root); + _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); + } + + private InstanceRegistry Register() => new( + _verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options); + + public async ValueTask DisposeAsync() + { + await Task.CompletedTask; + try { Directory.Delete(_root, recursive: true); } + catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } + } + + [Fact] + public async Task Eine_laufende_Instanz_wird_nach_vorn_geholt() + { + await using var register = Register(); + var pfad = register.Create("Alpha"); + + _kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2"); + await register.StartAsync(pfad); + + (await register.FocusAsync(pfad)).ShouldBeTrue(); + + _kanal.FocusRequests.ShouldBe([Path.GetFullPath(pfad)]); + + // Und vor allem: Es wurde nichts beendet und nichts neu gestartet. + _launcher.Starts.ShouldBe(1); + _kanal.StopRequests.ShouldBe(0); + _launcher.Last!.HasExited.ShouldBeFalse(); + } + + [Fact] + public async Task Eine_Instanz_die_nicht_laeuft_hat_kein_Fenster() + { + await using var register = Register(); + var pfad = register.Create("Alpha"); + + (await register.FocusAsync(pfad)).ShouldBeFalse(); + + // Nicht einmal gefragt: Wer nicht laeuft, antwortet auch nicht. + _kanal.FocusRequests.ShouldBeEmpty(); + } + + /// + /// Eine kopflose Instanz hat nichts nach vorn zu holen — und sagt das, statt Erfolg + /// zu melden. + /// + [Fact] + public async Task Eine_kopflose_Instanz_meldet_dass_sie_kein_Fenster_hat() + { + await using var register = Register(); + var pfad = register.Create("Alpha"); + + _kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2"); + await register.StartAsync(pfad); + + _kanal.HasWindow = false; + + (await register.FocusAsync(pfad)).ShouldBeFalse(); + _kanal.FocusRequests.ShouldHaveSingleItem(); + } +} diff --git a/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs b/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs index 7411834..fd626f0 100644 --- a/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs +++ b/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs @@ -4,6 +4,7 @@ using ClawdDotNet.App.Settings; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Instances; using ClawdDotNet.Core.Tools; +using ClawdDotNet.Web.Endpoints; using ClawdDotNet.Web.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; @@ -187,3 +188,110 @@ public sealed class InstanceSocketTests : IDisposable File.Exists(InstanceEndpoint.SocketPath(host.InstancePath)).ShouldBeFalse(); } } + +/// +/// Die Steuerung des Instanzprozesses: beenden und Fenster nach vorn holen +/// (#528, Stufen 3 und 5). +/// +/// Beides gibt es ausschließlich über den Socket. Über TCP existieren die +/// Endpunkte nicht — auch mit gültigem Token nicht, und sie antworten dort mit 404 statt +/// mit 403: Was es nicht gibt, muss man auch nicht erklären. Die Lebensdauer einer Instanz +/// gehört dem Instanzmanager; eine Browsersitzung soll den Prozess, der sie bedient, weder +/// beenden noch sein Fenster aufreißen können. +/// +public sealed class InstanceControlTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "ctl-" + Guid.NewGuid().ToString("N")[..8]); + + private const string Token = "ein-hinreichend-langes-testtoken"; + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } + catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } + } + + private AppHost NeuerHost(int port) + { + var instancesDir = Path.Combine(_root, "Instances"); + var instancePath = Path.Combine(instancesDir, "Test"); + Directory.CreateDirectory(instancePath); + + return new AppHost + { + Settings = new SettingsManager(_root), + Directories = new InstanceDirectoryManager(instancesDir), + Instance = new InstanceConfig + { + InstanceName = "Test", + WorkingDirectory = instancePath, + WebUiEnabled = true, + WebUiBinding = "loopback", + WebUiToken = Token, + WebServerPort = port + }, + InstancePath = instancePath, + LogDirectory = Path.Combine(instancePath, "Logs"), + LoggerFactory = NullLoggerFactory.Instance, + Tools = new ToolRegistry() + }; + } + + private static int FreierPort() + { + using var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + probe.Start(); + var port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + [Fact] + public async Task Ueber_den_Socket_kommt_die_Bitte_um_das_Fenster_an() + { + await using var host = NeuerHost(FreierPort()); + await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); + + web.ShouldNotBeNull(); + + var gebeten = 0; + web!.Control.FocusRequested += () => Interlocked.Increment(ref gebeten); + + using var client = InstanceEndpoint.CreateClient(host.InstancePath); + (await client.PostAsync("/api/instanz/fenster", content: null)).EnsureSuccessStatusCode(); + + gebeten.ShouldBe(1); + + // Und beliebig oft: Jeder Wechsel auf diese Instanz ist einer. + (await client.PostAsync("/api/instanz/fenster", content: null)).EnsureSuccessStatusCode(); + gebeten.ShouldBe(2); + } + + [Fact] + public async Task Ueber_TCP_gibt_es_die_Steuerung_nicht() + { + var port = FreierPort(); + + await using var host = NeuerHost(port); + await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance); + + web.ShouldNotBeNull(); + web!.Url.ShouldNotBeNull(); + + var gebeten = false; + web.Control.FocusRequested += () => gebeten = true; + + using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") }; + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}"); + + // Mit gueltigem Token - und trotzdem nicht vorhanden. + (await client.PostAsync("/api/instanz/fenster", content: null)).StatusCode + .ShouldBe(System.Net.HttpStatusCode.NotFound); + + (await client.PostAsync("/api/instanz/beenden", content: null)).StatusCode + .ShouldBe(System.Net.HttpStatusCode.NotFound); + + gebeten.ShouldBeFalse(); + } +}