Files
ClawdDotNet/tests/ClawdDotNet.Manager.Tests/ReportingTests.cs
T
RichardandClaude Opus 5 72b0764d1a Instanzmanager: Register, Einstellungen und stellvertretende Meldung (#528)
Der zweite Teil von Stufe 3. Die Aufsicht ueber eine einzelne Instanz stand
schon; jetzt gibt es die Klammer darum und den Weg nach draussen.

InstanceRegistry haelt je Instanz eine Aufsicht. Zwei Zusagen, beide aus
E-M6: Beim Start wird UEBERNOMMEN, nicht gestartet - ein Manager, der beim
Hochkommen alles hochfaehrt, was er findet, startet nach einem Neustart der
Maschine auch das, was jemand mit Absicht angehalten hat. Und beim Beenden
wird nichts beendet: Wer den Manager schliesst, will den Manager
schliessen. Einzige Ausnahme ist StopAllAsync fuer einen Grund, der alle
betrifft - und der heisst Lizenzwiderruf.

SupervisionReporter macht aus einem Befund eine Bugtracker-Meldung,
stellvertretend: mit Prozesskennung, Abschiedscode und dem Ausschnitt aus
dem Instanzprotokoll, der noch auf der Platte liegt. Der Idempotenzschluessel
ist stabil je Instanz und Art - stuerzt dieselbe Instanz wiederholt ab,
zaehlt der Server hoch, statt den Bugtracker mit Zwillingen zu fuellen.

Ein leerer Protokollauszug ist dabei die aussagekraeftigste Zeile der
Meldung und wird ausdruecklich als solche geschrieben. Genau das war der
Zustand am 2026-09-08: Der Prozess wurde erschlagen, bevor sein
Schreibpuffer geleert war, und hinterliess nichts.

manager.json traegt die Felder aus Abschnitt 6 des Konzepts und sonst
nichts. Lizenzschluessel, Master-Token und Web-Token liegen darin
verschluesselt und zur Laufzeit im Klartext - dieselbe Bauart wie die
Anwendungseinstellungen, mit Absicht. Unsinnige Aufsichtswerte werden
eingefangen, weil die Datei von Hand bearbeitbar ist.

InstanceLayout ist nach Core gezogen (ResolveLogDirectory,
ResolveBackupDirectory). Der Manager braucht das Protokollverzeichnis einer
Instanz fuer den Auszug in der Meldung und darf ClawdDotNet.App nicht
kennen; dieselbe Rechnung an zwei Stellen zu fuehren waere die schlechtere
Antwort. AppHost leitet nur noch weiter.

EIN DEFEKT, den erst die eigenen Tests gezeigt haben: Eine angehaltene
Instanz erschien im Register als "Instance-Alpha" statt "Alpha". Die
Aufsicht kennt den Namen erst, wenn die Instanz einmal geantwortet hat, und
eine angehaltene tut das nie - waehrend genau diese Ansicht der Manager
anzeigt. Das Register fuehrt den Namen jetzt aus InstanceSettings.json mit.

Und ein Testdouble, das zu viel behauptet hat: Der falsche Socket
antwortete fuer jedes Verzeichnis dieselbe Instanz. Das laesst Tests gruen
werden, die im Betrieb zwei Instanzen verwechseln wuerden; er antwortet
jetzt je Verzeichnis.

29 neue Tests. Build ohne Warnungen, 766 Tests gruen, 8 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:45:22 +02:00

164 lines
6.2 KiB
C#

using System.Net;
using System.Text;
using ClawdDotNet.Core.Deploymentcenter;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>Fängt die letzte Anfrage ab und antwortet fest, ohne Netzwerkzugriff.</summary>
internal sealed class CapturingHandler(
HttpStatusCode status = HttpStatusCode.OK,
string responseBody = """{"status":"success","item_id":1,"is_new":true,"occurrence_count":1}""")
: HttpMessageHandler
{
public string? LastBody { get; private set; }
public int Calls { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Calls++;
if (request.Content is not null)
LastBody = await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(status)
{
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
};
}
}
/// <summary>
/// Die stellvertretende Meldung (#528, Konzept Abschnitt 4).
///
/// <para>Am 2026-09-08 hat sich eine Instanz aufgehängt und wurde von Windows beendet:
/// keine Protokollzeile, keine Fehlermeldung, kein Bugtracker-Eintrag. Bauartbedingt —
/// Meldeweg und Watchdog lebten in dem Prozess, über den sie berichten sollten. Der
/// Manager meldet deshalb an ihrer Stelle, und zwar <b>nur</b> das, was aus seiner Sicht
/// relevant ist.</para>
/// </summary>
public sealed class SupervisionReporterTests
{
private readonly CapturingHandler _handler = new();
private SupervisionReporter Melder(Func<string, IReadOnlyList<string>>? auszug = null)
{
var api = new DeploymentcenterApi("https://dc.example", "t", new HttpClient(_handler));
var bugtracker = new BugtrackerClient(api, "clawddotnet", "production", "0.1.2");
return new SupervisionReporter(bugtracker, NullLogger.Instance, auszug ?? (_ => []));
}
private static SupervisionEvent Ereignis(
InstanceEventKind kind, int? exitCode = null, string dir = @"C:\Instanzen\Instance-Alpha")
=> new(kind, dir, "Alpha", 4711, exitCode, "Instanz Alpha ist unerwartet beendet worden.",
DateTimeOffset.Parse("2026-09-08T17:27:45Z"));
[Fact]
public async Task Ein_Absturz_geht_raus()
{
(await Melder().ReportAsync(Ereignis(InstanceEventKind.Crashed, exitCode: 137)))
.ShouldBeTrue();
_handler.Calls.ShouldBe(1);
var rumpf = _handler.LastBody.ShouldNotBeNull();
rumpf.ShouldContain("unerwartet beendet");
rumpf.ShouldContain("4711");
rumpf.ShouldContain("137");
rumpf.ShouldContain("instanzmanager");
rumpf.ShouldContain("clawddotnet"); // Projektbindung
rumpf.ShouldContain("0.1.2"); // build_version
}
/// <summary>
/// Was die Instanz betrifft, bleibt in der Instanz — sie meldet es selbst, mit ihrem
/// eigenen Sub-Token. Der Manager meldet nur, was niemand bestellt hat.
/// </summary>
[Theory]
[InlineData(InstanceEventKind.Started)]
[InlineData(InstanceEventKind.StoppedOrderly)]
[InlineData(InstanceEventKind.Recovered)]
public async Task Alltag_wird_nicht_gemeldet(InstanceEventKind kind)
{
(await Melder().ReportAsync(Ereignis(kind))).ShouldBeFalse();
_handler.Calls.ShouldBe(0);
}
/// <summary>
/// Stürzt dieselbe Instanz wiederholt ab, soll der Server hochzählen statt den
/// Bugtracker mit Zwillingen zu füllen — wiederholte Abstürze sind ein Befund und
/// nicht ein Dutzend gleicher.
/// </summary>
[Fact]
public async Task Dieselbe_Instanz_und_dieselbe_Art_ergeben_denselben_Schluessel()
{
var melder = Melder();
await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed));
var erster = SchluesselAus(_handler.LastBody!);
await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed));
SchluesselAus(_handler.LastBody!).ShouldBe(erster);
// Eine andere Art ist ein anderer Befund.
await melder.ReportAsync(Ereignis(InstanceEventKind.Unresponsive));
SchluesselAus(_handler.LastBody!).ShouldNotBe(erster);
// Und eine andere Instanz erst recht.
await melder.ReportAsync(
Ereignis(InstanceEventKind.Crashed, dir: @"C:\Instanzen\Instance-Beta"));
SchluesselAus(_handler.LastBody!).ShouldNotBe(erster);
}
[Fact]
public async Task Der_Protokollauszug_kommt_mit()
{
await Melder(_ => ["Startup.log: [17:26:50] [INF] Application is shutting down"])
.ReportAsync(Ereignis(InstanceEventKind.Crashed));
_handler.LastBody.ShouldNotBeNull().ShouldContain("Application is shutting down");
}
/// <summary>
/// Ein leerer Auszug ist die aussagekräftigste Zeile der ganzen Meldung: Ein Prozess,
/// der erschlagen wurde, bevor sein Schreibpuffer geleert war, hinterlässt nichts.
/// Genau das war der Zustand aus #529.
/// </summary>
[Fact]
public async Task Ein_leerer_Protokollauszug_wird_ausdruecklich_gesagt()
{
await Melder(_ => []).ReportAsync(Ereignis(InstanceEventKind.Crashed));
_handler.LastBody.ShouldNotBeNull().ShouldContain("KEINE");
}
/// <summary>
/// Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten: Der
/// ursprüngliche Befund ginge dabei verloren, und die Aufsicht stürbe an ihrem
/// eigenen Bericht.
/// </summary>
[Fact]
public async Task Ein_Serverfehler_reisst_die_Aufsicht_nicht_mit()
{
var handler = new CapturingHandler(HttpStatusCode.InternalServerError,
"""{"status":"error","error":{"code":"boom","message":"kaputt"}}""");
var api = new DeploymentcenterApi("https://dc.example", "t", new HttpClient(handler));
var melder = new SupervisionReporter(
new BugtrackerClient(api, "clawddotnet", "production", "0.1.2"),
NullLogger.Instance, _ => []);
(await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed))).ShouldBeFalse();
}
private static string SchluesselAus(string body)
{
using var doc = System.Text.Json.JsonDocument.Parse(body);
return doc.RootElement.GetProperty("client_ref").GetString()!;
}
}