diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js
index cba64af..e26d932 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js
@@ -3,9 +3,12 @@
// Verwaltet das Zugangstoken im sessionStorage und kapselt fetch-Aufrufe.
export const TOKEN_SCHLUESSEL = "clawd.token";
+export const INSTANZ_SCHLUESSEL = "clawd.instanz";
let token = sessionStorage.getItem(TOKEN_SCHLUESSEL) ?? "";
+let aktiveInstanz = sessionStorage.getItem(INSTANZ_SCHLUESSEL) ?? "";
const unberechtigtCallbacks = new Set();
+const instanzWechselCallbacks = new Set();
/**
* Gibt das aktuell gespeicherte Token zurück.
@@ -29,11 +32,58 @@ export function setzeToken(neuesToken) {
}
/**
- * Löscht das Token (Abmeldung).
+ * Löscht das Token und die aktive Instanz (Abmeldung).
*/
export function loescheToken() {
token = "";
sessionStorage.removeItem(TOKEN_SCHLUESSEL);
+ loescheAktiveInstanz();
+}
+
+/**
+ * Gibt die aktuell ausgewählte Instanz-ID zurück.
+ * @returns {string}
+ */
+export function holeAktiveInstanz() {
+ return aktiveInstanz;
+}
+
+/**
+ * Speichert die aktive Instanz im Speicher und sessionStorage.
+ * @param {string} neueInstanzId
+ */
+export function setzeAktiveInstanz(neueInstanzId) {
+ const id = (neueInstanzId || "").trim();
+ if (aktiveInstanz === id) return;
+ aktiveInstanz = id;
+ if (aktiveInstanz) {
+ sessionStorage.setItem(INSTANZ_SCHLUESSEL, aktiveInstanz);
+ } else {
+ sessionStorage.removeItem(INSTANZ_SCHLUESSEL);
+ }
+ for (const cb of instanzWechselCallbacks) {
+ try {
+ cb(aktiveInstanz);
+ } catch (err) {
+ console.error("Fehler im InstanzWechsel-Callback:", err);
+ }
+ }
+}
+
+/**
+ * Entfernt die aktive Instanz.
+ */
+export function loescheAktiveInstanz() {
+ setzeAktiveInstanz("");
+}
+
+/**
+ * Registriert einen Rückruf für Instanzwechsel.
+ * @param {(instanzId: string) => void} callback
+ */
+export function beiInstanzWechsel(callback) {
+ instanzWechselCallbacks.add(callback);
+ return () => instanzWechselCallbacks.delete(callback);
}
/**
@@ -66,6 +116,7 @@ function meldeUnberechtigt() {
/**
* Führt einen Aufruf gegen die API aus.
+ * Sendet bei gesetzter aktiver Instanz automatisch die X-Instanz-Kopfzeile mit.
* @param {string} pfad Pfad relativ zu /api, z.B. "/info" oder "/agents"
* @param {RequestInit} [optionen={}]
* @returns {Promise
}
@@ -76,6 +127,10 @@ export async function api(pfad, optionen = {}) {
Authorization: `Bearer ${token}`
};
+ if (aktiveInstanz && !kopfzeilen["X-Instanz"] && !pfad.startsWith("/verwaltung")) {
+ kopfzeilen["X-Instanz"] = aktiveInstanz;
+ }
+
if (optionen.body && typeof optionen.body === "object" && !(optionen.body instanceof FormData)) {
kopfzeilen["Content-Type"] = "application/json";
optionen.body = JSON.stringify(optionen.body);
@@ -98,12 +153,15 @@ export async function api(pfad, optionen = {}) {
let meldung = `HTTP ${antwort.status}`;
try {
const rumpf = await antwort.json();
- meldung = rumpf?.message ?? rumpf?.fehler ?? rumpf?.detail ?? meldung;
+ meldung = rumpf?.message ?? rumpf?.meldung ?? rumpf?.fehler ?? rumpf?.detail ?? meldung;
} catch {
// Wenn kein JSON geliefert wird, bleibt es beim HTTP-Code
}
const fehler = new Error(meldung);
fehler.status = antwort.status;
+ if (antwort.status === 402) fehler.lizenzFehlt = true;
+ if (antwort.status === 409) fehler.instanzKonflikt = true;
+ if (antwort.status === 502) fehler.instanzUnerreichbar = true;
throw fehler;
}
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/modules/dom.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/dom.js
index e141e92..90da2d4 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/modules/dom.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/modules/dom.js
@@ -109,9 +109,33 @@ export const PFADE = {
pfeilUnten: "M6 9l6 6 6-6",
haken: "M20 6L9 17l-5-5",
ausrufezeichen: "M12 8v4 M12 16h.01 M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z",
- ausloggen: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4 M16 17l5-5-5-5 M21 12H9"
+ ausloggen: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4 M16 17l5-5-5-5 M21 12H9",
+ server: "M2 20h20 M2 14h20 M2 8h20 M2 4h20 M6 6h.01 M6 12h.01 M6 18h.01",
+ fenster: "M3 3h18v18H3z M3 9h18 M9 21V9",
+ stop: "M6 6h12v12H6z"
};
+/**
+ * Übersetzt den technischen Instanzzustand in deutschen Text und Plakettenstil.
+ * @param {string} zustand "Running"|"Stopped"|"Silent"|"Crashed"|"Unknown"
+ * @returns {{ text: string, art: "erfolg"|"warnung"|"gefahr"|"neutral" }}
+ */
+export function instanzZustandInfo(zustand) {
+ const z = (zustand || "").toLowerCase();
+ switch (z) {
+ case "running":
+ return { text: "Läuft", art: "erfolg" };
+ case "stopped":
+ return { text: "Beendet", art: "neutral" };
+ case "silent":
+ return { text: "Verstummt", art: "warnung" };
+ case "crashed":
+ return { text: "Abgestürzt", art: "gefahr" };
+ default:
+ return { text: zustand || "Unbekannt", art: "neutral" };
+ }
+}
+
/**
* Erzeugt eine Statusplakette (Badge).
* @param {string} text
@@ -154,11 +178,12 @@ export function erstelleKarte(titel, elemente = [], optionen = {}) {
* @param {string} titel
* @param {HTMLElement} inhalt
* @param {HTMLElement[]} [aktionen]
+ * @param {object} [optionen]
* @returns {{ dialog: HTMLElement, oeffnen: () => void, schliessen: () => void }}
*/
-export function erstelleModal(titel, inhalt, aktionen = []) {
+export function erstelleModal(titel, inhalt, aktionen = [], optionen = {}) {
const overlay = erstelleElement("div", { klasse: "modal-overlay", hidden: true });
- const box = erstelleElement("div", { klasse: "modal-box karte" });
+ const box = erstelleElement("div", { klasse: `modal-box karte ${optionen.klasse ?? ""}`.trim() });
const kopf = erstelleElement("div", { klasse: "modal-kopf" });
const h = erstelleElement("h2", { text: titel });
@@ -172,16 +197,33 @@ export function erstelleModal(titel, inhalt, aktionen = []) {
const koerper = erstelleElement("div", { klasse: "modal-koerper" }, inhalt);
const fuss = erstelleElement("div", { klasse: "modal-fuss" });
- for (const aktion of aktionen) fuss.append(aktion);
+
+ // Wenn der Inhalt ein Formular ist oder enthält, verknüpfe Submit-Buttons im Fuß
+ const formElement = (inhalt && inhalt.tagName === "FORM")
+ ? inhalt
+ : (inhalt?.querySelector ? inhalt.querySelector("form") : null);
+
+ for (const aktion of aktionen) {
+ if (formElement && (aktion.type === "submit" || aktion.getAttribute?.("type") === "submit")) {
+ aktion.addEventListener("click", () => {
+ formElement.requestSubmit();
+ });
+ }
+ fuss.append(aktion);
+ }
box.append(kopf, koerper, fuss);
overlay.append(box);
document.body.append(overlay);
const schliessen = () => {
+ if (overlay.hidden) return;
overlay.hidden = true;
overlay.classList.remove("sichtbar");
document.removeEventListener("keydown", tasteHandler);
+ if (typeof optionen.onSchliessen === "function") {
+ optionen.onSchliessen();
+ }
};
const oeffnen = () => {
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/agent-chats.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/agent-chats.js
new file mode 100644
index 0000000..65d437d
--- /dev/null
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/agent-chats.js
@@ -0,0 +1,274 @@
+// ClawdDotNet — Interne Agenten-Kommunikation A2A (Vorhaben #208)
+//
+// Analyse und Protokollierung des Nachrichtenaustauschs zwischen Agenten.
+// - Filterbar nach beteiligten Agenten
+// - Dialog-Timeline zwischen Quelle und Ziel
+// - Live-Aktualisierung über Server-Sent Events (SSE "chat" mit source === "agentcomm")
+// Strikt DOM API, kein innerHTML.
+
+import { api, holeAktiveInstanz } from "../modules/api.js";
+import { abonnieren } from "../modules/events.js";
+import {
+ erstelleElement,
+ erstelleKarte,
+ erstellePlakette,
+ erstelleIcon,
+ PFADE,
+ formatiereDatumZeit
+} from "../modules/dom.js";
+
+let sseAbmeldung = null;
+
+export async function rendereAgentenChats() {
+ const container = document.getElementById("ansicht-inhalt");
+ if (!container) return;
+ container.replaceChildren();
+
+ if (sseAbmeldung) {
+ sseAbmeldung();
+ sseAbmeldung = null;
+ }
+
+ const instanzId = holeAktiveInstanz();
+ const layout = erstelleElement("div", { klasse: "agenten-chats-ansicht" });
+
+ // Kopfzeile
+ const kopf = erstelleElement("div", { klasse: "ansicht-kopf" });
+ const titelGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
+ titelGruppe.append(
+ erstelleElement("h1", { text: "Agenten-Kommunikation (A2A)" })
+ );
+
+ const filterGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
+
+ const agentSelect = erstelleElement("select", {
+ klasse: "eingabe",
+ stil: { minWidth: "180px" },
+ title: "Nach Agent filtern"
+ });
+ const optAlle = erstelleElement("option", { value: "", text: "Alle Agenten" });
+ agentSelect.append(optAlle);
+
+ const aktualisierenBtn = erstelleElement(
+ "button",
+ {
+ klasse: "schaltflaeche flach",
+ title: "Kommunikation neu laden",
+ onclick: () => ladeVerlauf()
+ },
+ erstelleIcon(PFADE.aktualisieren, 16),
+ erstelleElement("span", { text: " Aktualisieren" })
+ );
+
+ filterGruppe.append(agentSelect, aktualisierenBtn);
+ kopf.append(titelGruppe, filterGruppe);
+ layout.append(kopf);
+
+ if (!instanzId) {
+ layout.append(
+ erstelleKarte("Keine Instanz ausgewählt", [
+ erstelleElement("p", {
+ klasse: "hinweis",
+ text: "Bitte wähle oben im Kopfbereich eine Instanz aus, um deren Agenten-Kommunikation anzuzeigen."
+ })
+ ])
+ );
+ container.append(layout);
+ return;
+ }
+
+ // Infokarte
+ const infoKarte = erstelleKarte("Interne Kommunikation", [
+ erstelleElement("p", {
+ klasse: "gedaempft",
+ text: "Hier wird der direkte Nachrichtenaustausch zwischen Agenten (A2A) protokolliert, wenn Agenten über das Werkzeug 'SendMessage' miteinander interagieren."
+ })
+ ]);
+ layout.append(infoKarte);
+
+ // Verlauf-Container
+ const verlaufContainer = erstelleElement("div", { klasse: "a2a-timeline" });
+ layout.append(verlaufContainer);
+ container.append(layout);
+
+ let alleNachrichten = [];
+ let agentenListe = [];
+
+ // Agenten laden für Filter
+ try {
+ const agenten = await api("/agents");
+ agentenListe = Array.isArray(agenten) ? agenten : [];
+ for (const a of agentenListe) {
+ const opt = erstelleElement("option", {
+ value: a.agentId,
+ text: a.displayName || a.agentId
+ });
+ agentSelect.append(opt);
+ }
+ } catch {
+ // Wenn Agenten nicht ladbar sind, Filter leer lassen
+ }
+
+ agentSelect.addEventListener("change", () => {
+ rendereNachrichten();
+ });
+
+ async function ladeVerlauf() {
+ verlaufContainer.replaceChildren(
+ erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Kommunikationsverlauf…" })
+ );
+
+ try {
+ const daten = await api("/agents/communications");
+ alleNachrichten = Array.isArray(daten) ? daten : [];
+ rendereNachrichten();
+ } catch (err) {
+ verlaufContainer.replaceChildren(
+ erstelleKarte("Fehler", [
+ erstelleElement("p", {
+ klasse: "fehler-meldung",
+ text: `Kommunikation konnte nicht geladen werden: ${err.message}`
+ })
+ ])
+ );
+ }
+ }
+
+ function rendereNachrichten() {
+ const gewaehlterAgent = agentSelect.value;
+ const gefiltert = gewaehlterAgent
+ ? alleNachrichten.filter(
+ (m) =>
+ m.targetAgentId === gewaehlterAgent ||
+ m.sourceAgentId === gewaehlterAgent
+ )
+ : alleNachrichten;
+
+ if (gefiltert.length === 0) {
+ verlaufContainer.replaceChildren(
+ erstelleKarte("Keine Nachrichten", [
+ erstelleElement("p", {
+ klasse: "gedaempft",
+ text: gewaehlterAgent
+ ? "Für den ausgewählten Agenten liegt noch keine interne Kommunikation vor."
+ : "In dieser Instanz wurde bisher keine interne Agent-zu-Agent-Kommunikation protokolliert."
+ })
+ ])
+ );
+ return;
+ }
+
+ verlaufContainer.replaceChildren();
+
+ for (const m of gefiltert) {
+ const eintrag = erstelleNachrichtElement(m);
+ verlaufContainer.append(eintrag);
+ }
+ }
+
+ function erstelleNachrichtElement(m) {
+ const istAntwort = m.role === "assistant";
+ const kasten = erstelleElement("div", {
+ klasse: `a2a-eintrag ${istAntwort ? "a2a-antwort" : "a2a-anfrage"}`
+ });
+
+ const kopf = erstelleElement("div", { klasse: "a2a-eintrag-kopf" });
+ const absenderGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
+
+ if (istAntwort) {
+ // Antwort vom Zielagenten
+ absenderGruppe.append(
+ erstellePlakette(m.targetAgentName || m.targetAgentId, "erfolg"),
+ erstelleElement("span", { klasse: "a2a-pfeil", text: "➔ Antwort" })
+ );
+ } else {
+ // Anfrage/Nachricht von Quelle an Ziel
+ const quelleName = m.sourceAgentName || m.sourceAgentId || "Agent";
+ const zielName = m.targetAgentName || m.targetAgentId;
+ absenderGruppe.append(
+ erstellePlakette(quelleName, "neutral"),
+ erstelleElement("span", { klasse: "a2a-pfeil", text: "➔" }),
+ erstellePlakette(zielName, "primaer")
+ );
+ }
+
+ const zeit = erstelleElement("span", {
+ klasse: "a2a-zeit",
+ text: formatiereDatumZeit(m.timestamp)
+ });
+
+ kopf.append(absenderGruppe, zeit);
+
+ const inhalt = erstelleElement("pre", {
+ klasse: "a2a-inhalt",
+ text: m.content
+ });
+
+ kasten.append(kopf, inhalt);
+ return kasten;
+ }
+
+ // SSE Live-Aktualisierung
+ sseAbmeldung = abonnieren("chat", (daten) => {
+ if (daten?.source === "agentcomm") {
+ // Neue Nachricht kam live herein: an die Liste anhängen
+ const zielAgent = agentenListe.find((a) => a.agentId === daten.agentId);
+ const zielName = zielAgent ? zielAgent.displayName : daten.agentId;
+
+ let senderId = null;
+ let senderName = null;
+ let content = daten.content || "";
+
+ if (daten.role === "user" && content.startsWith("[Nachricht von Agent \"")) {
+ const endQuote = content.indexOf('"', 22);
+ if (endQuote > 22) {
+ senderName = content.slice(22, endQuote);
+ const openParen = content.indexOf("(", endQuote);
+ const closeParen = content.indexOf(")", openParen);
+ if (openParen > 0 && closeParen > openParen) {
+ senderId = content.slice(openParen + 1, closeParen);
+ }
+ }
+ const doubleNl = content.indexOf("\n\n");
+ if (doubleNl > 0) {
+ content = content.slice(doubleNl + 2);
+ }
+ }
+
+ const neueNachricht = {
+ targetAgentId: daten.agentId,
+ targetAgentName: zielName,
+ sourceAgentId: senderId,
+ sourceAgentName: senderName,
+ role: daten.role,
+ content: content,
+ timestamp: daten.timestamp || new Date().toISOString()
+ };
+
+ alleNachrichten.push(neueNachricht);
+
+ const gewaehlterAgent = agentSelect.value;
+ if (
+ !gewaehlterAgent ||
+ neueNachricht.targetAgentId === gewaehlterAgent ||
+ neueNachricht.sourceAgentId === gewaehlterAgent
+ ) {
+ // Falls vorher "Keine Nachrichten" da stand:
+ const leerKarte = verlaufContainer.querySelector(".karte");
+ if (leerKarte) verlaufContainer.replaceChildren();
+
+ const el = erstelleNachrichtElement(neueNachricht);
+ verlaufContainer.append(el);
+ }
+ }
+ });
+
+ ladeVerlauf();
+}
+
+export function trenneAgentenChatsStream() {
+ if (sseAbmeldung) {
+ sseAbmeldung();
+ sseAbmeldung = null;
+ }
+}
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js
index 6a2d545..af4e0d7 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js
@@ -74,12 +74,16 @@ async function ladeUndRendereListe(zielId = null) {
listeContainer.replaceChildren();
if (!agenten || agenten.length === 0) {
- listeContainer.append(
- erstelleElement("div", {
- klasse: "hinweis-zentriert",
- text: "Keine Agenten eingerichtet."
- })
+ const leerKasten = erstelleElement("div", { klasse: "hinweis-zentriert" });
+ leerKasten.append(
+ erstelleElement("p", { klasse: "gedaempft", text: "In dieser Instanz sind noch keine Agenten eingerichtet." }),
+ erstelleElement("button", {
+ klasse: "schaltflaeche primaer klein",
+ onclick: oeffneNeuerAgentDialog,
+ style: "margin-top: 10px;"
+ }, erstelleIcon(PFADE.plus, 14), erstelleElement("span", { text: " Ersten Agenten anlegen" }))
);
+ listeContainer.append(leerKasten);
rendereDetails(null);
return;
}
@@ -123,8 +127,13 @@ async function ladeUndRendereListe(zielId = null) {
rendereDetails(auswahlId);
} catch (err) {
+ const text = err.instanzUnerreichbar
+ ? "Die ausgewählte Instanz läuft derzeit nicht. Bitte starte sie zuerst im Kopfbereich oder unter 'Instanzen'."
+ : (err.instanzKonflikt
+ ? "Keine aktive Instanz gewählt. Bitte wähle oben eine Instanz aus."
+ : err.message);
listeContainer.replaceChildren(
- erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
+ erstelleElement("div", { klasse: "fehler-zentriert", text })
);
}
}
@@ -260,7 +269,10 @@ async function rendereDetails(agentId) {
}
function oeffneNeuerAgentDialog() {
- const formular = erstelleElement("form", { klasse: "formular-raster" });
+ const formular = erstelleElement("form", {
+ klasse: "formular-raster",
+ id: "neuer-agent-formular"
+ });
const idFeld = erstelleElement("input", {
type: "text",
@@ -315,7 +327,11 @@ function oeffneNeuerAgentDialog() {
"button",
{
type: "submit",
- klasse: "schaltflaeche primaer"
+ klasse: "schaltflaeche primaer",
+ form: "neuer-agent-formular",
+ onclick: () => {
+ formular.requestSubmit();
+ }
},
erstelleElement("span", { text: "Agent anlegen" })
);
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js
new file mode 100644
index 0000000..4aa77ce
--- /dev/null
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js
@@ -0,0 +1,426 @@
+// ClawdDotNet — Datensicherung & Wiederherstellung (Vorhaben #530, Stufe 6)
+//
+// Verwaltet Sicherungen (.zip) der aktiven Instanz:
+// - Auflistung aller Archive im Sicherungsverzeichnis
+// - Neue Sicherung anlegen (Passphrase-Schutz, Logs, Chatverlauf)
+// - Wiederherstellung (mit geordnetem Stop/Start der Instanz)
+// - Löschen von Archiven
+// Strikt DOM API, kein innerHTML.
+
+import { api, holeAktiveInstanz } from "../modules/api.js";
+import {
+ erstelleElement,
+ erstelleKarte,
+ erstellePlakette,
+ erstelleIcon,
+ erstelleModal,
+ PFADE,
+ formatiereDatumZeit,
+ zeigeMeldung
+} from "../modules/dom.js";
+
+function formatiereBytes(bytes) {
+ if (bytes === 0) return "0 B";
+ if (!bytes) return "—";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
+}
+
+export async function rendereSicherung() {
+ const container = document.getElementById("ansicht-inhalt");
+ if (!container) return;
+ container.replaceChildren();
+
+ const instanzId = holeAktiveInstanz();
+ const layout = erstelleElement("div", { klasse: "sicherung-ansicht" });
+
+ // Kopfzeile
+ const kopf = erstelleElement("div", { klasse: "ansicht-kopf" });
+ const titelGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
+ titelGruppe.append(erstelleElement("h1", { text: "Datensicherung & Wiederherstellung" }));
+
+ const aktionenGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
+
+ const aktualisierenBtn = erstelleElement(
+ "button",
+ {
+ klasse: "schaltflaeche flach",
+ title: "Sicherungen neu laden",
+ onclick: () => ladeSicherungen()
+ },
+ erstelleIcon(PFADE.aktualisieren, 16),
+ erstelleElement("span", { text: " Aktualisieren" })
+ );
+
+ const neuBtn = erstelleElement(
+ "button",
+ {
+ klasse: "schaltflaeche primaer",
+ title: "Neue Sicherung erstellen",
+ disabled: !instanzId,
+ onclick: () => oeffneSicherungsDialog()
+ },
+ erstelleIcon(PFADE.plus, 16),
+ erstelleElement("span", { text: " Neue Sicherung" })
+ );
+
+ aktionenGruppe.append(aktualisierenBtn, neuBtn);
+ kopf.append(titelGruppe, aktionenGruppe);
+ layout.append(kopf);
+
+ if (!instanzId) {
+ layout.append(
+ erstelleKarte("Keine Instanz ausgewählt", [
+ erstelleElement("p", {
+ klasse: "hinweis",
+ text: "Bitte wähle oben im Kopfbereich eine Instanz aus, um deren Sicherungen zu verwalten."
+ })
+ ])
+ );
+ container.append(layout);
+ return;
+ }
+
+ // Verzeichnis-Info & Inhalt
+ const infoBereich = erstelleElement("div", { klasse: "sicherung-info-bereich" });
+ const inhaltBereich = erstelleElement("div", { klasse: "sicherung-inhalt-bereich" });
+
+ layout.append(infoBereich, inhaltBereich);
+ container.append(layout);
+
+ async function ladeSicherungen() {
+ inhaltBereich.replaceChildren(
+ erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Sicherungen…" })
+ );
+
+ try {
+ const daten = await api(`/verwaltung/instanzen/${instanzId}/backups`);
+ const verzeichnis = daten?.directory ?? "—";
+ const backups = daten?.backups ?? [];
+
+ infoBereich.replaceChildren(
+ erstelleKarte("Sicherungsverzeichnis", [
+ erstelleElement("p", { klasse: "gedaempft", text: "Sicherungen werden komprimiert als ZIP-Archiv abgelegt. Die Datenbank (state.db) wird konsistent per VACUUM INTO kopiert." }),
+ erstelleElement("div", { klasse: "ordner-pfad-anzeige" },
+ erstelleIcon(PFADE.server, 14),
+ erstelleElement("code", { text: verzeichnis })
+ )
+ ])
+ );
+
+ if (backups.length === 0) {
+ inhaltBereich.replaceChildren(
+ erstelleKarte("Vorhandene Sicherungen", [
+ erstelleElement("p", {
+ klasse: "gedaempft",
+ text: "Für diese Instanz sind noch keine Sicherungsarchive vorhanden. Erstelle über die Schaltfläche oben eine neue Sicherung."
+ })
+ ])
+ );
+ return;
+ }
+
+ const tabelle = erstelleElement("table", { klasse: "tabelle sicherungen-tabelle" });
+ const thead = erstelleElement("thead", {},
+ erstelleElement("tr", {},
+ erstelleElement("th", { text: "Archivdatei" }),
+ erstelleElement("th", { text: "Erstellt am" }),
+ erstelleElement("th", { text: "Größe" }),
+ erstelleElement("th", { text: "Zugangsdaten" }),
+ erstelleElement("th", { text: "Dateien" }),
+ erstelleElement("th", { klasse: "spalte-aktionen", text: "Aktionen" })
+ )
+ );
+
+ const tbody = erstelleElement("tbody");
+
+ for (const b of backups) {
+ const tr = erstelleElement("tr");
+
+ // Dateiname & Icon
+ const dateiTd = erstelleElement("td", { klasse: "dateiname-zelle" },
+ erstelleIcon(PFADE.sicherung, 16),
+ erstelleElement("strong", { text: b.fileName })
+ );
+
+ // Datum
+ const datumTd = erstelleElement("td", { text: formatiereDatumZeit(b.createdAt) });
+
+ // Größe
+ const groesseTd = erstelleElement("td", { text: formatiereBytes(b.sizeBytes) });
+
+ // Zugangsdaten Modus
+ const hatPassphrase = b.secrets === "Passphrase";
+ const secretsPlakette = erstellePlakette(
+ hatPassphrase ? `Passphrase (${b.secretCount})` : "Ausgeschlossen",
+ hatPassphrase ? "erfolg" : "neutral"
+ );
+ const secretsTd = erstelleElement("td", {}, secretsPlakette);
+
+ // Dateienanzahl
+ const dateienTd = erstelleElement("td", { text: b.fileCount > 0 ? `${b.fileCount} Dateien` : "—" });
+
+ // Aktionen
+ const aktionenTd = erstelleElement("td", { klasse: "spalte-aktionen" });
+
+ const restoreBtn = erstelleElement(
+ "button",
+ {
+ klasse: "schaltflaeche klein sekundär",
+ title: "Diese Sicherung wiederherstellen",
+ onclick: () => oeffneRestoreDialog(b)
+ },
+ erstelleIcon(PFADE.abspielen, 14),
+ erstelleElement("span", { text: " Wiederherstellen" })
+ );
+
+ const deleteBtn = erstelleElement(
+ "button",
+ {
+ klasse: "schaltflaeche klein gefahr flach",
+ title: "Sicherungsdatei unwiderruflich löschen",
+ onclick: () => oeffneLoeschenDialog(b.fileName)
+ },
+ erstelleIcon(PFADE.muelleimer, 14)
+ );
+
+ aktionenTd.append(restoreBtn, deleteBtn);
+ tr.append(dateiTd, datumTd, groesseTd, secretsTd, dateienTd, aktionenTd);
+ tbody.append(tr);
+ }
+
+ tabelle.append(thead, tbody);
+ inhaltBereich.replaceChildren(
+ erstelleKarte(`Vorhandene Sicherungen (${backups.length})`, [tabelle])
+ );
+ } catch (err) {
+ inhaltBereich.replaceChildren(
+ erstelleKarte("Fehler", [
+ erstelleElement("p", { klasse: "fehler-meldung", text: `Sicherungen konnten nicht geladen werden: ${err.message}` })
+ ])
+ );
+ }
+ }
+
+ // ─── Modal: Neue Sicherung erstellen ───
+ function oeffneSicherungsDialog() {
+ const form = erstelleElement("form", { klasse: "formular" });
+
+ // Modus Zugangsdaten
+ const secretsGruppe = erstelleElement("div", { klasse: "form-gruppe" });
+ secretsGruppe.append(
+ erstelleElement("label", { text: "Zugangsdaten & Schlüssel" }),
+ erstelleElement("p", { klasse: "form-hilfe", text: "DPAPI-geschützte Daten können mit einer Passphrase geschützt oder sicherheitshalber entfernt werden." })
+ );
+
+ const secretsSelect = erstelleElement("select", { klasse: "eingabe" });
+ const optExclude = erstelleElement("option", { value: "Exclude", text: "Entfernen (gefahrlos ablegbar, Schlüssel müssen neu eingetragen werden)" });
+ const optPassphrase = erstelleElement("option", { value: "Passphrase", text: "Mit Passphrase schützen (vollständig übertragbar)" });
+ secretsSelect.append(optExclude, optPassphrase);
+ secretsGruppe.append(secretsSelect);
+
+ // Passphrase Feld
+ const passGruppe = erstelleElement("div", { klasse: "form-gruppe", stil: { display: "none" } });
+ const passInput = erstelleElement("input", {
+ type: "password",
+ klasse: "eingabe",
+ placeholder: "Passphrase für Verschlüsselung eingeben…"
+ });
+ passGruppe.append(
+ erstelleElement("label", { text: "Passphrase" }),
+ passInput
+ );
+
+ secretsSelect.addEventListener("change", () => {
+ passGruppe.style.display = secretsSelect.value === "Passphrase" ? "block" : "none";
+ if (secretsSelect.value === "Passphrase") passInput.focus();
+ });
+
+ // Checkboxen
+ const chatsGruppe = erstelleElement("div", { klasse: "form-gruppe-checkbox" });
+ const chatsCheckbox = erstelleElement("input", { type: "checkbox", id: "backup-chk-chats", checked: true });
+ const chatsLabel = erstelleElement("label", { for: "backup-chk-chats", text: "Chatverläufe sichern" });
+ chatsGruppe.append(chatsCheckbox, chatsLabel);
+
+ const logsGruppe = erstelleElement("div", { klasse: "form-gruppe-checkbox" });
+ const logsCheckbox = erstelleElement("input", { type: "checkbox", id: "backup-chk-logs", checked: false });
+ const logsLabel = erstelleElement("label", { for: "backup-chk-logs", text: "Protokolldateien (Logs) einschließen" });
+ logsGruppe.append(logsCheckbox, logsLabel);
+
+ form.append(secretsGruppe, passGruppe, chatsGruppe, logsGruppe);
+
+ let modal;
+ const abbrechenBtn = erstelleElement("button", {
+ type: "button",
+ klasse: "schaltflaeche",
+ text: "Abbrechen",
+ onclick: () => modal.schliessen()
+ });
+
+ const speichernBtn = erstelleElement("button", {
+ type: "submit",
+ klasse: "schaltflaeche primaer",
+ text: "Sicherung erstellen"
+ });
+
+ form.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const secrets = secretsSelect.value;
+ const passphrase = passInput.value.trim();
+
+ if (secrets === "Passphrase" && !passphrase) {
+ zeigeMeldung("Bitte gib eine Passphrase ein.", "warnung");
+ passInput.focus();
+ return;
+ }
+
+ speichernBtn.disabled = true;
+ speichernBtn.textContent = "Erstelle Sicherung…";
+
+ try {
+ const res = await api(`/verwaltung/instanzen/${instanzId}/backups`, {
+ method: "POST",
+ body: {
+ secrets,
+ passphrase: passphrase || null,
+ includeLogs: logsCheckbox.checked,
+ includeChatHistory: chatsCheckbox.checked
+ }
+ });
+ zeigeMeldung(res.meldung || "Sicherung erfolgreich erstellt.", "erfolg");
+ modal.schliessen();
+ ladeSicherungen();
+ } catch (err) {
+ zeigeMeldung(`Fehler beim Sichern: ${err.message}`, "gefahr");
+ speichernBtn.disabled = false;
+ speichernBtn.textContent = "Sicherung erstellen";
+ }
+ });
+
+ modal = erstelleModal("Neue Sicherung erstellen", form, [abbrechenBtn, speichernBtn]);
+ modal.oeffnen();
+ }
+
+ // ─── Modal: Wiederherstellen ───
+ function oeffneRestoreDialog(backup) {
+ const form = erstelleElement("form", { klasse: "formular" });
+
+ const warnung = erstelleElement("div", { klasse: "hinweis-box warnung" },
+ erstelleIcon(PFADE.ausrufezeichen, 18),
+ erstelleElement("div", {},
+ erstelleElement("strong", { text: "Achtung: Überschreiben bestehender Daten" }),
+ erstelleElement("p", {
+ text: `Die Wiederherstellung überschreibt die aktuellen Konfigurationen und Daten der Instanz mit dem Stand vom ${formatiereDatumZeit(backup.createdAt)}.`
+ }),
+ erstelleElement("p", {
+ text: "Die Instanz wird dafür kurz geordnet gestoppt und nach erfolgreicher Rücksicherung wieder hochgefahren."
+ })
+ )
+ );
+
+ form.append(warnung);
+
+ let passInput = null;
+ if (backup.secrets === "Passphrase") {
+ const passGruppe = erstelleElement("div", { klasse: "form-gruppe", stil: { marginTop: "16px" } });
+ passInput = erstelleElement("input", {
+ type: "password",
+ klasse: "eingabe",
+ placeholder: "Passphrase der Sicherung eingeben…",
+ required: true
+ });
+ passGruppe.append(
+ erstelleElement("label", { text: "Passphrase für Zugangsdaten" }),
+ passInput
+ );
+ form.append(passGruppe);
+ }
+
+ let modal;
+ const abbrechenBtn = erstelleElement("button", {
+ type: "button",
+ klasse: "schaltflaeche",
+ text: "Abbrechen",
+ onclick: () => modal.schliessen()
+ });
+
+ const restoreBtn = erstelleElement("button", {
+ type: "submit",
+ klasse: "schaltflaeche warnung",
+ text: "Jetzt Wiederherstellen"
+ });
+
+ form.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const passphrase = passInput?.value?.trim() || null;
+
+ if (backup.secrets === "Passphrase" && !passphrase) {
+ zeigeMeldung("Bitte gib die Passphrase an.", "warnung");
+ passInput?.focus();
+ return;
+ }
+
+ restoreBtn.disabled = true;
+ restoreBtn.textContent = "Stelle wieder her…";
+
+ try {
+ const res = await api(`/verwaltung/instanzen/${instanzId}/backups/${encodeURIComponent(backup.fileName)}/restore`, {
+ method: "POST",
+ body: { passphrase }
+ });
+ zeigeMeldung(res.meldung || "Wiederherstellung erfolgreich abgeschlossen.", "erfolg");
+ modal.schliessen();
+ ladeSicherungen();
+ } catch (err) {
+ zeigeMeldung(`Wiederherstellung fehlgeschlagen: ${err.message}`, "gefahr");
+ restoreBtn.disabled = false;
+ restoreBtn.textContent = "Jetzt Wiederherstellen";
+ }
+ });
+
+ modal = erstelleModal(`Sicherung wiederherstellen: ${backup.fileName}`, form, [abbrechenBtn, restoreBtn]);
+ modal.oeffnen();
+ }
+
+ // ─── Modal: Löschen ───
+ function oeffneLoeschenDialog(fileName) {
+ const text = erstelleElement("p", {
+ text: `Soll die Sicherungsdatei '${fileName}' wirklich unwiderruflich gelöscht werden?`
+ });
+
+ let modal;
+ const abbrechenBtn = erstelleElement("button", {
+ type: "button",
+ klasse: "schaltflaeche",
+ text: "Abbrechen",
+ onclick: () => modal.schliessen()
+ });
+
+ const loeschenBtn = erstelleElement("button", {
+ type: "button",
+ klasse: "schaltflaeche gefahr",
+ text: "Löschen",
+ onclick: async () => {
+ loeschenBtn.disabled = true;
+ try {
+ const res = await api(`/verwaltung/instanzen/${instanzId}/backups/${encodeURIComponent(fileName)}`, {
+ method: "DELETE"
+ });
+ zeigeMeldung(res.meldung || "Sicherung gelöscht.", "erfolg");
+ modal.schliessen();
+ ladeSicherungen();
+ } catch (err) {
+ zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
+ loeschenBtn.disabled = false;
+ }
+ }
+ });
+
+ modal = erstelleModal("Sicherung löschen", text, [abbrechenBtn, loeschenBtn]);
+ modal.oeffnen();
+ }
+
+ ladeSicherungen();
+}
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/chat.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/chat.js
index ffc3bab..3f56946 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/chat.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/chat.js
@@ -2,7 +2,7 @@
//
// Leitfaden: Sicherer Chatverlauf (strikt textContent), Echtzeit-Ereignisse über SSE.
-import { api } from "../modules/api.js";
+import { api, holeAktiveInstanz } from "../modules/api.js";
import { abonnieren } from "../modules/events.js";
import {
erstelleElement,
@@ -131,7 +131,8 @@ export async function rendereChat(parameter = {}) {
// SSE-Abonnements
entladeEreignisse.push(
abonnieren("chat", (daten) => {
- if (daten.agentId === aktiverAgentId) {
+ const aktiveId = holeAktiveInstanz();
+ if (daten.agentId === aktiverAgentId && (!daten.instanz || !aktiveId || daten.instanz === aktiveId)) {
fuegeNachrichtEin(daten);
}
})
@@ -139,7 +140,8 @@ export async function rendereChat(parameter = {}) {
entladeEreignisse.push(
abonnieren("step", (daten) => {
- if (daten.agentId === aktiverAgentId) {
+ const aktiveId = holeAktiveInstanz();
+ if (daten.agentId === aktiverAgentId && (!daten.instanz || !aktiveId || daten.instanz === aktiveId)) {
zeigeSchritt(daten.text);
}
})
@@ -147,14 +149,18 @@ export async function rendereChat(parameter = {}) {
entladeEreignisse.push(
abonnieren("run", (daten) => {
- versteckeSchritt();
- aktualisiereLaufStatus(false);
+ const aktiveId = holeAktiveInstanz();
+ if (!daten.instanz || !aktiveId || daten.instanz === aktiveId) {
+ versteckeSchritt();
+ aktualisiereLaufStatus(false);
+ }
})
);
entladeEreignisse.push(
abonnieren("instance", (daten) => {
- if (daten.kind === "Agents") {
+ const aktiveId = holeAktiveInstanz();
+ if (daten.kind === "Agents" && (!daten.instanz || !aktiveId || daten.instanz === aktiveId)) {
ladeAgenten(aktiverAgentId);
}
})
@@ -286,6 +292,10 @@ function fuegeNachrichtEin(eintrag) {
});
kopf.append(autor);
+ if (eintrag.instanzName) {
+ const instanzBadge = erstelleElement("span", { klasse: "quelle-tag", text: eintrag.instanzName });
+ kopf.append(instanzBadge);
+ }
if (eintrag.source) {
const quelleBadge = erstelleElement("span", { klasse: "quelle-tag", text: eintrag.source });
kopf.append(quelleBadge);
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/info.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/info.js
index 7824d21..03a09f4 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/info.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/info.js
@@ -2,7 +2,7 @@
//
// Zeigt Anwendungsversion, Instanzkennung, Teilsysteme und OpenRouter-Guthaben an.
-import { api } from "../modules/api.js";
+import { api, holeAktiveInstanz } from "../modules/api.js";
import {
erstelleElement,
erstelleKarte,
@@ -21,6 +21,18 @@ export async function rendereInfo() {
layout.append(gitter);
container.append(layout);
+ if (!holeAktiveInstanz()) {
+ gitter.append(
+ erstelleKarte("Keine Instanz ausgewählt", [
+ erstelleElement("p", {
+ klasse: "hinweis",
+ text: "Wähle oben im Kopfbereich eine aktive Instanz aus oder wechsle in den Bereich 'Instanzen', um eine Instanz zu starten."
+ })
+ ])
+ );
+ return;
+ }
+
gitter.append(
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Systemdaten…" })
);
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/instances.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/instances.js
new file mode 100644
index 0000000..503454b
--- /dev/null
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/instances.js
@@ -0,0 +1,433 @@
+// ClawdDotNet — Instanzverwaltung
+//
+// Leitfaden: Übersicht, Anlegen, Starten, Anhalten, Desktop-Fenster nach vorn holen,
+// Auswahl der aktiven Instanz für das WebUI. Kein innerHTML, strikt textContent.
+
+import { api, holeAktiveInstanz, setzeAktiveInstanz } from "../modules/api.js";
+import {
+ erstelleElement,
+ erstelleIcon,
+ PFADE,
+ erstellePlakette,
+ erstelleKarte,
+ erstelleModal,
+ instanzZustandInfo,
+ zeigeMeldung
+} from "../modules/dom.js";
+
+let aktualisierungsIntervall = null;
+
+export async function rendereInstanzen() {
+ const container = document.getElementById("ansicht-inhalt");
+ if (!container) return;
+ container.replaceChildren();
+
+ stoppePolling();
+
+ const layout = erstelleElement("div", { klasse: "instanzen-ansicht" });
+
+ const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
+ const titelGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
+ titelGruppe.append(erstelleElement("h1", { text: "Instanzverwaltung" }));
+
+ const aktionenGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
+
+ const aktualisierenBtn = erstelleElement(
+ "button",
+ {
+ klasse: "schaltflaeche flach",
+ title: "Instanzliste manuell aktualisieren",
+ onclick: () => ladeInstanzen()
+ },
+ erstelleIcon(PFADE.aktualisieren, 16),
+ erstelleElement("span", { text: " Aktualisieren" })
+ );
+
+ const neuBtn = erstelleElement(
+ "button",
+ {
+ klasse: "schaltflaeche primaer",
+ title: "Neue Instanz anlegen",
+ onclick: oeffneNeueInstanzDialog
+ },
+ erstelleIcon(PFADE.plus, 16),
+ erstelleElement("span", { text: " Neue Instanz" })
+ );
+
+ aktionenGruppe.append(aktualisierenBtn, neuBtn);
+ kopfZeile.append(titelGruppe, aktionenGruppe);
+
+ const raster = erstelleElement("div", { klasse: "instanzen-raster", id: "instanzen-raster" });
+
+ layout.append(kopfZeile, raster);
+ container.append(layout);
+
+ await ladeInstanzen();
+
+ // Hintergrund-Polling alle 4 Sekunden, da sich Zustände ohne Klick ändern können
+ aktualisierungsIntervall = setInterval(() => {
+ ladeInstanzen(true);
+ }, 4000);
+}
+
+export function stoppePolling() {
+ if (aktualisierungsIntervall) {
+ clearInterval(aktualisierungsIntervall);
+ aktualisierungsIntervall = null;
+ }
+}
+
+async function ladeInstanzen(istHintergrund = false) {
+ const raster = document.getElementById("instanzen-raster");
+ if (!raster) {
+ stoppePolling();
+ return;
+ }
+
+ if (!istHintergrund && raster.children.length === 0) {
+ raster.replaceChildren(
+ erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Instanzen…" })
+ );
+ }
+
+ try {
+ const instanzen = await api("/verwaltung/instanzen");
+ if (!raster) return;
+ raster.replaceChildren();
+
+ if (!Array.isArray(instanzen) || instanzen.length === 0) {
+ raster.append(
+ erstelleKarte("Keine Instanzen gefunden", [
+ erstelleElement("p", {
+ klasse: "gedaempft",
+ text: "Es sind noch keine Instanzen eingerichtet. Lege mit 'Neue Instanz' eine an."
+ })
+ ])
+ );
+ return;
+ }
+
+ const aktiveId = holeAktiveInstanz();
+
+ for (const instanz of instanzen) {
+ const istAktiv = instanz.id === aktiveId;
+ const zustandInfo = instanzZustandInfo(instanz.zustand);
+ const istLaufend = instanz.zustand === "Running";
+ const istStumm = instanz.zustand === "Silent";
+ const istAbgestuerzt = instanz.zustand === "Crashed";
+
+ // Instanzkarte
+ const karteKopfAktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
+ if (istAktiv) {
+ karteKopfAktionen.append(erstellePlakette("Aktiv im WebUI", "erfolg"));
+ }
+ karteKopfAktionen.append(erstellePlakette(zustandInfo.text, zustandInfo.art));
+
+ // Details
+ const dl = erstelleElement("dl", { klasse: "datenliste" });
+ dl.append(
+ erstelleElement("dt", { text: "Instanz-Kennung" }),
+ erstelleElement("dd", {}, erstelleElement("code", { text: instanz.id })),
+ erstelleElement("dt", { text: "Zustand" }),
+ erstelleElement("dd", { text: zustandInfo.text }),
+ erstelleElement("dt", { text: "Prozess (PID)" }),
+ erstelleElement("dd", { text: instanz.prozess ? String(instanz.prozess) : "—" }),
+ erstelleElement("dt", { text: "Autostart durch Manager" }),
+ erstelleElement("dd", {
+ text: instanz.autostart ? "Aktiviert" : "Aus",
+ klasse: instanz.autostart ? "" : "gedaempft"
+ })
+ );
+
+ // Aktionsleiste
+ const fussAktionen = erstelleElement("div", { klasse: "instanz-aktionen" });
+
+ // Auswählen-Knopf
+ const waehlenBtn = erstelleElement(
+ "button",
+ {
+ klasse: istAktiv ? "schaltflaeche klein" : "schaltflaeche primaer klein",
+ disabled: istAktiv,
+ title: istAktiv ? "Diese Instanz wird aktuell im WebUI bedient" : "Diese Instanz für das WebUI auswählen",
+ onclick: () => {
+ setzeAktiveInstanz(instanz.id);
+ zeigeMeldung(`Instanz '${instanz.name}' im WebUI ausgewählt.`, "info");
+ ladeInstanzen();
+ }
+ },
+ erstelleElement("span", { text: istAktiv ? "Ausgewählt" : "Auswählen" })
+ );
+
+ // Starten-Knopf
+ const startenBtn = erstelleElement(
+ "button",
+ {
+ klasse: "schaltflaeche flach klein",
+ disabled: istLaufend,
+ title: istLaufend ? "Instanz läuft bereits" : "Instanz starten",
+ onclick: () => starteInstanz(instanz)
+ },
+ erstelleIcon(PFADE.abspielen, 14),
+ erstelleElement("span", { text: " Starten" })
+ );
+
+ // Anhalten-Knopf
+ const anhaltenBtn = erstelleElement(
+ "button",
+ {
+ klasse: "schaltflaeche gefahr flach klein",
+ disabled: !istLaufend && !istStumm,
+ title: (!istLaufend && !istStumm) ? "Instanz läuft nicht" : "Instanz geordnet anhalten",
+ onclick: () => stoppeInstanz(instanz)
+ },
+ erstelleIcon(PFADE.stop, 14),
+ erstelleElement("span", { text: " Anhalten" })
+ );
+
+ // Fenster nach vorn
+ const fensterBtn = erstelleElement(
+ "button",
+ {
+ klasse: "schaltflaeche flach klein",
+ disabled: !istLaufend,
+ title: !istLaufend ? "Instanz läuft nicht" : "Desktop-Fenster dieser Instanz in den Vordergrund holen",
+ onclick: () => holeFensterNachVorn(instanz)
+ },
+ erstelleIcon(PFADE.fenster, 14),
+ erstelleElement("span", { text: " Fenster" })
+ );
+
+ fussAktionen.append(waehlenBtn, startenBtn, anhaltenBtn, fensterBtn);
+
+ const karte = erstelleKarte(instanz.name, [dl, fussAktionen], {
+ aktionen: karteKopfAktionen,
+ klasse: istAktiv ? "instanz-karte aktiv" : "instanz-karte"
+ });
+
+ raster.append(karte);
+ }
+ } catch (err) {
+ if (!istHintergrund) {
+ raster.replaceChildren(
+ erstelleElement("div", { klasse: "fehler-zentriert", text: `Fehler beim Laden: ${err.message}` })
+ );
+ }
+ }
+}
+
+async function starteInstanz(instanz) {
+ try {
+ const antwort = await api(`/verwaltung/instanzen/${encodeURIComponent(instanz.id)}/start`, {
+ method: "POST"
+ });
+ zeigeMeldung(antwort?.meldung || `Instanz '${instanz.name}' wird gestartet…`, "erfolg");
+ // Wenn bisher keine Instanz im WebUI gewählt war, diese direkt wählen
+ if (!holeAktiveInstanz()) {
+ setzeAktiveInstanz(instanz.id);
+ }
+ await ladeInstanzen();
+ } catch (err) {
+ if (err.lizenzFehlt) {
+ zeigeMeldung("Start abgelehnt: Ohne gültige Lizenz startet keine Instanz (HTTP 402).", "gefahr", 6000);
+ } else {
+ zeigeMeldung(`Start fehlgeschlagen: ${err.message}`, "gefahr");
+ }
+ }
+}
+
+function stoppeInstanz(instanz) {
+ oeffneSicherheitsStoppDialog(instanz);
+}
+
+function oeffneSicherheitsStoppDialog(instanz) {
+ let verbleibend = 10;
+ let timerId = null;
+
+ const inhalt = erstelleElement("div", { klasse: "formular-raster" });
+
+ // Sicherheits-Warnbox
+ const sicherheitsBox = erstelleElement("div", { klasse: "sicherheits-box" });
+ const boxTitel = erstelleElement(
+ "h3",
+ {},
+ erstelleIcon(PFADE.ausrufezeichen, 18),
+ erstelleElement("span", { text: "Achtung: Produktivinstanz beenden" })
+ );
+ const boxText1 = erstelleElement("p", {
+ text: "ClawdDotNet-Instanzen sind auf Dauerbetrieb ausgelegt. Das Beenden stoppt alle aktiven Agenten, Automationen, Web-Zugriffe und Hintergrundaufgaben dieser Instanz geordnet."
+ });
+ const boxText2 = erstelleElement("p", {
+ text: "Um ein versehentliches Beenden durch unbedachtes Klicken zu verhindern, ist die Bestätigung für 10 Sekunden gesperrt."
+ });
+ sicherheitsBox.append(boxTitel, boxText1, boxText2);
+
+ // Instanzdetails
+ const dl = erstelleElement("dl", { klasse: "datenliste" });
+ dl.append(
+ erstelleElement("dt", { text: "Instanz" }),
+ erstelleElement("dd", {}, erstelleElement("strong", { text: instanz.name })),
+ erstelleElement("dt", { text: "Kennung" }),
+ erstelleElement("dd", {}, erstelleElement("code", { text: instanz.id })),
+ erstelleElement("dt", { text: "Prozess (PID)" }),
+ erstelleElement("dd", { text: instanz.prozess ? String(instanz.prozess) : "—" })
+ );
+
+ // Countdown-Anzeige
+ const countdownBox = erstelleElement("div", {
+ klasse: "sicherheits-countdown",
+ text: `Bestätigung gesperrt: Noch ${verbleibend} Sekunden…`
+ });
+
+ inhalt.append(sicherheitsBox, dl, countdownBox);
+
+ // Aktionen
+ const abbrechenBtn = erstelleElement("button", {
+ type: "button",
+ klasse: "schaltflaeche flach",
+ onclick: () => modal.schliessen(),
+ text: "Abbrechen (Instanz läuft weiter)"
+ });
+
+ const anhaltenBtn = erstelleElement(
+ "button",
+ {
+ type: "button",
+ klasse: "schaltflaeche gefahr",
+ disabled: true
+ },
+ erstelleIcon(PFADE.stop, 14),
+ erstelleElement("span", { text: ` Instanz anhalten (${verbleibend}s)` })
+ );
+
+ const modal = erstelleModal(
+ `Instanz anhalten — ${instanz.name}`,
+ inhalt,
+ [abbrechenBtn, anhaltenBtn],
+ {
+ onSchliessen: () => {
+ if (timerId) {
+ clearInterval(timerId);
+ timerId = null;
+ }
+ }
+ }
+ );
+
+ timerId = setInterval(() => {
+ verbleibend--;
+ if (verbleibend > 0) {
+ countdownBox.textContent = `Bestätigung gesperrt: Noch ${verbleibend} Sekunden…`;
+ const textSpan = anhaltenBtn.querySelector("span");
+ if (textSpan) textSpan.textContent = ` Instanz anhalten (${verbleibend}s)`;
+ } else {
+ clearInterval(timerId);
+ timerId = null;
+ anhaltenBtn.disabled = false;
+ const textSpan = anhaltenBtn.querySelector("span");
+ if (textSpan) textSpan.textContent = " Instanz jetzt endgültig anhalten";
+ countdownBox.className = "sicherheits-countdown abgelaufen";
+ countdownBox.textContent = "Sperre aufgehoben — Bestätigung jetzt möglich.";
+ }
+ }, 1000);
+
+ anhaltenBtn.addEventListener("click", async () => {
+ if (verbleibend > 0) return;
+ anhaltenBtn.disabled = true;
+ const textSpan = anhaltenBtn.querySelector("span");
+ if (textSpan) textSpan.textContent = " Wird angehalten…";
+
+ try {
+ const antwort = await api(`/verwaltung/instanzen/${encodeURIComponent(instanz.id)}/stop`, {
+ method: "POST"
+ });
+ modal.schliessen();
+ zeigeMeldung(antwort?.meldung || `Instanz '${instanz.name}' beendet.`, "info");
+ await ladeInstanzen();
+ } catch (err) {
+ zeigeMeldung(`Anhalten fehlgeschlagen: ${err.message}`, "gefahr");
+ anhaltenBtn.disabled = false;
+ if (textSpan) textSpan.textContent = " Instanz jetzt endgültig anhalten";
+ }
+ });
+
+ modal.oeffnen();
+}
+
+async function holeFensterNachVorn(instanz) {
+ try {
+ const antwort = await api(`/verwaltung/instanzen/${encodeURIComponent(instanz.id)}/fenster`, {
+ method: "POST"
+ });
+ zeigeMeldung(antwort?.meldung || `Fenster von '${instanz.name}' nach vorn geholt.`, "info");
+ } catch (err) {
+ zeigeMeldung(`Fensteraufruf fehlgeschlagen: ${err.message}`, "warnung");
+ }
+}
+
+function oeffneNeueInstanzDialog() {
+ const formular = erstelleElement("form", {
+ klasse: "formular-raster",
+ id: "neue-instanz-formular"
+ });
+
+ const nameInput = erstelleElement("input", {
+ type: "text",
+ required: true,
+ placeholder: "z.B. Produktion oder Agenten-Labor",
+ autocomplete: "off"
+ });
+
+ const hinweis = erstelleElement("p", {
+ klasse: "hinweis",
+ text: "Der Instanzmanager legt dafür ein eigenes Verzeichnis mit getrennten Protokollen und Einstellungen an."
+ });
+
+ formular.append(
+ erstelleElement("label", { text: "Name der neuen Instanz:" }),
+ nameInput,
+ hinweis
+ );
+
+ const abbrechenBtn = erstelleElement("button", {
+ type: "button",
+ klasse: "schaltflaeche flach",
+ onclick: () => modal.schliessen(),
+ text: "Abbrechen"
+ });
+
+ // Kein eigenes onclick und kein form-Attribut: erstelleModal verknüpft Submit-Knöpfe
+ // im Fuß selbst mit dem Formular. Beides zusammen löste requestSubmit() zweimal aus —
+ // ein Klick, zwei POST, zwei Instanzen. `disabled` hilft dagegen nicht, denn
+ // form.requestSubmit() schert sich nicht um den Zustand eines Knopfes.
+ const erstellenBtn = erstelleElement("button", {
+ type: "submit",
+ klasse: "schaltflaeche primaer",
+ text: "Instanz anlegen"
+ });
+
+ const modal = erstelleModal("Neue Instanz anlegen", formular, [abbrechenBtn, erstellenBtn]);
+
+ formular.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const name = nameInput.value.trim();
+ if (!name) return;
+
+ erstellenBtn.disabled = true;
+
+ try {
+ const antwort = await api("/verwaltung/instanzen", {
+ method: "POST",
+ body: { name }
+ });
+ modal.schliessen();
+ zeigeMeldung(antwort?.meldung || `Instanz '${name}' erfolgreich angelegt.`, "erfolg");
+ await ladeInstanzen();
+ } catch (err) {
+ zeigeMeldung(`Fehler beim Anlegen: ${err.message}`, "gefahr");
+ } finally {
+ erstellenBtn.disabled = false;
+ }
+ });
+
+ modal.oeffnen();
+}
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/login.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/login.js
index c2ce552..156b692 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/login.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/login.js
@@ -29,10 +29,10 @@ export function initialisiereLogin(onErfolg) {
setzeToken(token);
try {
- // Teste das Token gegen /api/info
- const info = await api("/info");
+ // Teste das Token gegen /api/verwaltung/instanzen (gehört dem Manager direkt und scheitert nie an Instanzanzahl)
+ const instanzen = await api("/verwaltung/instanzen");
versteckeAnmeldung();
- if (loginCallback) loginCallback(info);
+ if (loginCallback) loginCallback(instanzen);
} catch (err) {
loescheToken();
if (fehlerBox) {
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/logs.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/logs.js
index 14a6d6b..28d7a04 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/logs.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/logs.js
@@ -12,6 +12,7 @@ import {
} from "../modules/dom.js";
let logStream = null;
+let instanzFilter = "";
let modulFilter = "";
let levelFilter = "All";
let autoScroll = true;
@@ -38,6 +39,16 @@ export async function rendereProtokoll() {
const steuerungGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
+ // Instanz-Auswahl
+ const instanzSelect = erstelleElement("select", {
+ klasse: "eingabe-select",
+ title: "Nach Instanz filtern",
+ onchange: (e) => {
+ instanzFilter = e.target.value;
+ }
+ });
+ instanzSelect.append(erstelleElement("option", { value: "", text: "Alle Instanzen" }));
+
// Modul-Auswahl
const modulSelect = erstelleElement("select", {
klasse: "eingabe-select",
@@ -102,13 +113,25 @@ export async function rendereProtokoll() {
erstelleElement("span", { text: " Leeren" })
);
- steuerungGruppe.append(modulSelect, levelSelect, autoScrollLabel, pauseButton, leerenBtn);
+ steuerungGruppe.append(instanzSelect, modulSelect, levelSelect, autoScrollLabel, pauseButton, leerenBtn);
kopfZeile.append(titelGruppe, steuerungGruppe);
logContainer = erstelleElement("div", { klasse: "protokoll-strom code-block" });
layout.append(kopfZeile, logContainer);
container.append(layout);
+ // Lade verfügbare Instanzen für Filter
+ try {
+ const instanzen = await api("/verwaltung/instanzen");
+ if (Array.isArray(instanzen)) {
+ for (const inst of instanzen) {
+ instanzSelect.append(erstelleElement("option", { value: inst.id, text: inst.name }));
+ }
+ }
+ } catch {
+ // Nicht kritisch
+ }
+
// Lade verfügbare Module
try {
const module = await api("/logs/modules");
@@ -159,8 +182,22 @@ function starteLogStream() {
function fuegeLogZeileEin(eintrag) {
if (!logContainer) return;
+ // Filter nach Instanz falls gewählt
+ if (instanzFilter && eintrag.instanz && eintrag.instanz !== instanzFilter && eintrag.instanzName !== instanzFilter) {
+ return;
+ }
+
const zeile = erstelleElement("div", { klasse: "log-zeile" });
+ if (eintrag.instanzName || eintrag.instanz) {
+ zeile.append(
+ erstelleElement("span", {
+ klasse: "log-instanz",
+ text: `[${eintrag.instanzName || eintrag.instanz}]`
+ })
+ );
+ }
+
if (eintrag.level) {
const lvl = String(eintrag.level).toLowerCase();
const badgeArt =
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/placeholders.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/placeholders.js
index 0689253..fb5a05c 100644
--- a/src/ClawdDotNet.Manager.Web/wwwroot/views/placeholders.js
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/placeholders.js
@@ -27,28 +27,6 @@ export function rendereSicherung() {
container.append(layout);
}
-export function rendereEinstellungen() {
- const container = document.getElementById("ansicht-inhalt");
- if (!container) return;
- container.replaceChildren();
-
- const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
- layout.append(erstelleElement("h1", { text: "Instanz-Einstellungen" }));
-
- const karte = erstelleKarte("In Vorbereitung", [
- erstelleElement("p", {
- klasse: "gedaempft",
- text: "Die Konfiguration der Instanz (Port, Token, Logging, Budget) erfolgt über die Datei 'InstanceSettings.json' bzw. über den Einstellungsdialog im Desktop-Fenster."
- }),
- erstelleElement("p", {
- klasse: "hinweis",
- text: "Ein dedizierter Web-Endpunkt zum Schreiben sensibler Zugangsdaten ist für einen späteren Meilenstein vorgesehen."
- })
- ]);
-
- layout.append(karte);
- container.append(layout);
-}
export function rendereAgentenChats() {
const container = document.getElementById("ansicht-inhalt");
diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js
new file mode 100644
index 0000000..559f5c1
--- /dev/null
+++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js
@@ -0,0 +1,245 @@
+// ClawdDotNet — Einstellungen & Systemarchitektur
+//
+// Strikte Trennung zwischen globaler Manager-Ebene (Installation) und isolierter Instanz-Ebene.
+
+import { api, holeAktiveInstanz } from "../modules/api.js";
+import {
+ erstelleElement,
+ erstelleKarte,
+ erstellePlakette,
+ instanzZustandInfo,
+ erstelleIcon,
+ PFADE
+} from "../modules/dom.js";
+
+export async function rendereEinstellungen() {
+ const container = document.getElementById("ansicht-inhalt");
+ if (!container) return;
+ container.replaceChildren();
+
+ const layout = erstelleElement("div", { klasse: "einstellungen-ansicht" });
+
+ // Kopfbereich
+ const kopf = erstelleElement("div", { klasse: "ansicht-kopf" });
+ const kopfText = erstelleElement("div");
+ kopfText.append(
+ erstelleElement("h1", { text: "Einstellungen & Systemarchitektur" }),
+ erstelleElement("p", {
+ klasse: "hinweis",
+ text: "Übersicht der Konfiguration nach dem Instanzmanager-Konzept: Strikte Trennung zwischen globaler Manager-Ebene (Installation) und isolierter Instanz-Ebene."
+ })
+ );
+ kopf.append(kopfText);
+ layout.append(kopf);
+
+ // ═══════════════════════════════════════════════════════════════
+ // SEKTION 1: Instanzmanager (Globale Ebene / Installation)
+ // ═══════════════════════════════════════════════════════════════
+
+ const managerSektion = erstelleElement("section", { klasse: "einstellungen-sektion sektion-manager" });
+
+ const managerKopf = erstelleElement("div", { klasse: "sektion-titel-zeile" });
+ const managerTitelLinks = erstelleElement("div", { klasse: "gruppe-horizontal" });
+ managerTitelLinks.append(
+ erstelleIcon(PFADE.server, 20),
+ erstelleElement("h2", { text: "Instanzmanager" }),
+ erstellePlakette("Manager-Ebene · Global", "warnung")
+ );
+ managerKopf.append(managerTitelLinks);
+
+ const managerIntro = erstelleElement("p", {
+ klasse: "gedaempft text-klein",
+ text: "Gilt für alle Instanzen und die Weboberfläche. Gespeichert in manager.json im Anwendungs-Hauptverzeichnis."
+ });
+
+ const managerGitter = erstelleElement("div", { klasse: "info-gitter" });
+
+ // Karte 1: Weboberfläche & Netzwerk
+ const dlNetz = erstelleElement("dl", { klasse: "datenliste" });
+ dlNetz.append(
+ erstelleElement("dt", { text: "Konfigurationsdatei" }),
+ erstelleElement("dd", {}, erstelleElement("code", { text: "manager.json" })),
+ erstelleElement("dt", { text: "WebUI-Port" }),
+ erstelleElement("dd", { text: "7000 (Vorgabe, webUiPort)" }),
+ erstelleElement("dt", { text: "Netzwerk-Bindung" }),
+ erstelleElement("dd", { text: "loopback (127.0.0.1, webUiBinding)" }),
+ erstelleElement("dt", { text: "Zugangsschutz" }),
+ erstelleElement("dd", {}, erstellePlakette("Token aktiv (≥ 20 Zeichen)", "erfolg"))
+ );
+ managerGitter.append(erstelleKarte("Weboberfläche & Netzwerk", [dlNetz]));
+
+ // Karte 2: Lizenz & Programm-Updates
+ const dlLizenz = erstelleElement("dl", { klasse: "datenliste" });
+ dlLizenz.append(
+ erstelleElement("dt", { text: "Lizenz-Geltung" }),
+ erstelleElement("dd", { text: "Installationsebene (E-M3: Beliebig viele Instanzen)" }),
+ erstelleElement("dt", { text: "Lizenzschlüssel" }),
+ erstelleElement("dd", {}, erstelleElement("code", { text: "●●●●-●●●●-●●●●-●●●● (verschlüsselt)" })),
+ erstelleElement("dt", { text: "Update-Kanal" }),
+ erstelleElement("dd", { text: "prod (Offizielle Release-Versionen)" }),
+ erstelleElement("dt", { text: "Update-Prüfung" }),
+ erstelleElement("dd", { text: "Zentral auf Manager-Ebene" })
+ );
+ managerGitter.append(erstelleKarte("Lizenz & Updates", [dlLizenz]));
+
+ // Karte 3: Deploymentcenter & Aufsicht
+ const dlAufsicht = erstelleElement("dl", { klasse: "datenliste" });
+ dlAufsicht.append(
+ erstelleElement("dt", { text: "Deploymentcenter" }),
+ erstelleElement("dd", { text: "https://dc.mhdf.de" }),
+ erstelleElement("dt", { text: "Master-Token" }),
+ erstelleElement("dd", {}, erstelleElement("code", { text: "●●●●●●●● (erzeugt Sub-Token je Instanz)" })),
+ erstelleElement("dt", { text: "Desktop-Fenster" }),
+ erstelleElement("dd", { text: "Aktiv (jede Instanz hat ein eigenes Fenster)" }),
+ erstelleElement("dt", { text: "Absturz-Schutz" }),
+ erstelleElement("dd", { text: "Max. 5 Neustarts / 10 Minuten" })
+ );
+ managerGitter.append(erstelleKarte("Deploymentcenter & Aufsicht", [dlAufsicht]));
+
+ managerSektion.append(managerKopf, managerIntro, managerGitter);
+ layout.append(managerSektion);
+
+ // ═══════════════════════════════════════════════════════════════
+ // SEKTION 2: Aktive Instanz (Instanz-Ebene)
+ // ═══════════════════════════════════════════════════════════════
+
+ const instanzSektion = erstelleElement("section", { klasse: "einstellungen-sektion sektion-instanz" });
+
+ const aktiveId = holeAktiveInstanz();
+ let instanzenListe = [];
+ try {
+ instanzenListe = await api("/verwaltung/instanzen");
+ } catch {
+ // Wenn Liste nicht abrufbar, weiter
+ }
+
+ const aktiveInstanz = instanzenListe.find((i) => i.id === aktiveId);
+ const zInfo = aktiveInstanz ? instanzZustandInfo(aktiveInstanz.zustand) : { text: "Keine gewählt", art: "neutral" };
+
+ const instanzKopf = erstelleElement("div", { klasse: "sektion-titel-zeile" });
+ const instanzTitelLinks = erstelleElement("div", { klasse: "gruppe-horizontal" });
+ instanzTitelLinks.append(
+ erstelleIcon(PFADE.fenster, 20),
+ erstelleElement("h2", { text: aktiveInstanz ? `Aktive Instanz: ${aktiveInstanz.name}` : "Instanzbezogene Einstellungen" }),
+ erstellePlakette("Instanz-Ebene · Isoliert", "erfolg"),
+ aktiveInstanz ? erstellePlakette(zInfo.text, zInfo.art) : erstellePlakette("Keine Instanz aktiv", "neutral")
+ );
+ instanzKopf.append(instanzTitelLinks);
+
+ const instanzIntro = erstelleElement("p", {
+ klasse: "gedaempft text-klein",
+ text: "Gilt ausschließlich für die aktuell ausgewählte Instanz. Alle Pfade, Protokolle, Agenten und Budgets sind strikt von anderen Instanzen isoliert."
+ });
+
+ instanzSektion.append(instanzKopf, instanzIntro);
+
+ if (!aktiveInstanz) {
+ const leerKarte = erstelleKarte("Keine Instanz ausgewählt", [
+ erstelleElement("p", {
+ klasse: "hinweis",
+ text: "Bitte wähle oben im Kopfbereich eine aktive Instanz aus oder wechsle in den Bereich 'Instanzen', um eine Instanz zu starten oder auszuwählen."
+ }),
+ erstelleElement("a", {
+ href: "#/instanzen",
+ klasse: "schaltflaeche primaer klein",
+ text: "Zur Instanzen-Übersicht"
+ })
+ ]);
+ instanzSektion.append(leerKarte);
+ } else {
+ // Daten von der laufenden Instanz abfragen (sofern sie läuft)
+ const [instanzInfo, instanzStatus] = await Promise.all([
+ api("/info").catch(() => null),
+ api("/status").catch(() => null)
+ ]);
+
+ const instanzGitter = erstelleElement("div", { klasse: "info-gitter" });
+
+ // Karte 1: Instanz-Identität & Autostart
+ const dlIdent = erstelleElement("dl", { klasse: "datenliste" });
+ dlIdent.append(
+ erstelleElement("dt", { text: "Instanzname" }),
+ erstelleElement("dd", {}, erstelleElement("strong", { text: aktiveInstanz.name })),
+ erstelleElement("dt", { text: "Instanz-ID" }),
+ erstelleElement("dd", {}, erstelleElement("code", { text: aktiveInstanz.id })),
+ erstelleElement("dt", { text: "Konfigurationsdatei" }),
+ erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/InstanceSettings.json` })),
+ erstelleElement("dt", { text: "Autostart durch Manager" }),
+ erstelleElement("dd", {}, erstellePlakette(aktiveInstanz.autostart ? "Aktiv (Am Laufen halten)" : "Inaktiv (Manueller Start)", aktiveInstanz.autostart ? "erfolg" : "neutral")),
+ erstelleElement("dt", { text: "Kommunikation" }),
+ erstelleElement("dd", { text: "HTTP über Named Pipe (Windows) / Socket (Linux)" })
+ );
+ instanzGitter.append(erstelleKarte("Identität & Prozess", [dlIdent]));
+
+ // Karte 2: Isolierte Verzeichnisse & Speicher
+ const dlPfade = erstelleElement("dl", { klasse: "datenliste" });
+ dlPfade.append(
+ erstelleElement("dt", { text: "Protokolle (Logs)" }),
+ erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Logs/` })),
+ erstelleElement("dt", { text: "Datensicherungen" }),
+ erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Backups/` })),
+ erstelleElement("dt", { text: "Zustandsdatenbank" }),
+ erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/state.db (exklusiv)` })),
+ erstelleElement("dt", { text: "Arbeitsbereich (Shared)" }),
+ erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Agents/SharedWorkspace/` }))
+ );
+ instanzGitter.append(erstelleKarte("Isolierte Verzeichnisse", [dlPfade]));
+
+ // Karte 3: Modellanbindung & Budget (OpenRouter)
+ const dlModell = erstelleElement("dl", { klasse: "datenliste" });
+ if (instanzStatus) {
+ dlModell.append(
+ erstelleElement("dt", { text: "OpenRouter-Status" }),
+ erstelleElement("dd", {}, erstellePlakette(instanzStatus.isApiReachable ? "Verbunden" : "Offline", instanzStatus.isApiReachable ? "erfolg" : "gefahr")),
+ erstelleElement("dt", { text: "Statusmeldung" }),
+ erstelleElement("dd", { text: instanzStatus.statusText || "—" }),
+ erstelleElement("dt", { text: "Guthaben / Credits" }),
+ erstelleElement("dd", {}, erstelleElement("strong", { text: instanzStatus.creditsText || "—" })),
+ erstelleElement("dt", { text: "API-Schlüssel" }),
+ erstelleElement("dd", { text: "In InstanceSettings.json dieser Instanz maskiert hinterlegt" })
+ );
+ } else {
+ dlModell.append(
+ erstelleElement("dt", { text: "OpenRouter" }),
+ erstelleElement("dd", {
+ klasse: "gedaempft",
+ text: aktiveInstanz.zustand === "Running"
+ ? "Kein Schlüssel hinterlegt oder Dienst nicht erreichbar."
+ : "Instanz läuft nicht. Daten werden nach dem Start geladen."
+ })
+ );
+ }
+ instanzGitter.append(erstelleKarte("Modellanbindung (OpenRouter)", [dlModell]));
+
+ // Karte 4: Teilsysteme der Instanz
+ const dlSysteme = erstelleElement("dl", { klasse: "datenliste" });
+ if (instanzInfo) {
+ dlSysteme.append(
+ erstelleElement("dt", { text: "Agent Engine" }),
+ erstelleElement("dd", {}, erstellePlakette(instanzInfo.engineActive ? "Aktiv" : "Aus", instanzInfo.engineActive ? "erfolg" : "neutral")),
+ erstelleElement("dt", { text: "Scanner / Scheduler" }),
+ erstelleElement("dd", {}, erstellePlakette(instanzInfo.schedulerActive ? "Aktiv" : "Aus", instanzInfo.schedulerActive ? "erfolg" : "neutral")),
+ erstelleElement("dt", { text: "Freigabestelle (Staging)" }),
+ erstelleElement("dd", {}, erstellePlakette(instanzInfo.stagingActive ? "Aktiv" : "Aus", instanzInfo.stagingActive ? "erfolg" : "neutral")),
+ erstelleElement("dt", { text: "Deploymentcenter Sub-Token" }),
+ erstelleElement("dd", {}, erstellePlakette(instanzInfo.deploymentcenterActive ? "Aktiv" : "Aus", instanzInfo.deploymentcenterActive ? "erfolg" : "neutral"))
+ );
+ } else {
+ dlSysteme.append(
+ erstelleElement("dt", { text: "Subsysteme" }),
+ erstelleElement("dd", {
+ klasse: "gedaempft",
+ text: aktiveInstanz.zustand === "Running"
+ ? "Laufzeitdaten werden abgefragt…"
+ : "Instanz ist beendet — Teilsysteme ruhen."
+ })
+ );
+ }
+ instanzGitter.append(erstelleKarte("Laufzeit & Teilsysteme", [dlSysteme]));
+
+ instanzSektion.append(instanzGitter);
+ }
+
+ layout.append(instanzSektion);
+ container.append(layout);
+}
diff --git a/src/ClawdDotNet.Manager/InstanceChannel.cs b/src/ClawdDotNet.Manager/InstanceChannel.cs
index 9816995..ff39826 100644
--- a/src/ClawdDotNet.Manager/InstanceChannel.cs
+++ b/src/ClawdDotNet.Manager/InstanceChannel.cs
@@ -3,8 +3,34 @@ using ClawdDotNet.Core.Instances;
namespace ClawdDotNet.Manager;
-/// Was eine Instanz über sich sagt, wenn man sie über ihren Socket fragt.
-public sealed record InstanceIdentity(string InstanceId, string InstanceName, string AppVersion);
+///
+/// Was eine Instanz über sich sagt, wenn man sie über ihren Socket fragt — ihr Puls.
+///
+/// Die Identität ist der Beweis, der Rest ist die Auskunft. Dass überhaupt
+/// jemand antwortet, unterscheidet „läuft" von „tot". Erst
+/// und unterscheiden die beiden Fälle, die
+/// gleich aussehen und gegensätzlich zu behandeln sind: eine Instanz, die hängt, und
+/// eine, die gerade geordnet aufräumt.
+///
+/// Läuft, fährt herunter, ist fertig.
+/// Wie viele Arbeiten beim Herunterfahren noch auslaufen.
+/// Was das im Klartext ist, fürs Protokoll.
+public sealed record InstanceIdentity(
+ string InstanceId,
+ string InstanceName,
+ string AppVersion,
+ ShutdownPhase Phase = ShutdownPhase.Running,
+ int OpenWork = 0,
+ string OpenSummary = "")
+{
+ ///
+ /// Fährt diese Instanz gerade geordnet herunter und ist dabei noch beschäftigt?
+ ///
+ /// Die Aufsicht darf so eine Instanz nicht abschießen — sie tut genau das,
+ /// worum gebeten wurde.
+ ///
+ public bool IsWindingDown => Phase == ShutdownPhase.Draining;
+}
///
/// Das Gespräch mit einer Instanz über ihren Socket: Wer bist du — und bitte beende dich.
@@ -74,18 +100,26 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC
using var client = InstanceEndpoint.CreateClient(instanceDirectory);
client.Timeout = _timeout;
- using var antwort = await client.GetAsync("/api/info", ct).ConfigureAwait(false);
+ // Der Puls und nicht /api/info: Er liegt hinter dem Socket-Gate, gehoert also
+ // dem Manager allein, und er sagt zusaetzlich, ob die Instanz gerade
+ // aufraeumt. Von dieser Unterscheidung haengt ab, ob abgeschossen wird.
+ using var antwort = await client
+ .GetAsync("/api/instanz/puls", ct).ConfigureAwait(false);
if (!antwort.IsSuccessStatusCode)
return null;
var json = await antwort.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
return new InstanceIdentity(
- Text(doc.RootElement, "instanceId"),
- Text(doc.RootElement, "instanceName"),
- Text(doc.RootElement, "appVersion"));
+ Text(root, "instanceId"),
+ Text(root, "instanceName"),
+ Text(root, "appVersion"),
+ Phase(root),
+ Zahl(root, "offenGesamt"),
+ Beschreibung(root));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -139,4 +173,31 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC
=> root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() ?? ""
: "";
+
+ private static int Zahl(JsonElement root, string name)
+ => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
+ ? v.GetInt32()
+ : 0;
+
+ ///
+ /// Die Phase aus der Antwort — im Zweifel Running.
+ ///
+ /// Ein unbekannter Wert darf hier nicht als „fährt herunter" durchgehen: Das
+ /// wäre der Freibrief, mit dem eine hängende Instanz beliebig lange stehen bliebe.
+ /// Running ist die Auslegung, bei der die Aufsicht wachsam bleibt.
+ ///
+ private static ShutdownPhase Phase(JsonElement root)
+ => Enum.TryParse(Text(root, "phase"), ignoreCase: true, out var phase)
+ ? phase
+ : ShutdownPhase.Running;
+
+ private static string Beschreibung(JsonElement root)
+ {
+ if (!root.TryGetProperty("offeneArbeiten", out var liste)
+ || liste.ValueKind != JsonValueKind.Array)
+ return "";
+
+ return string.Join(", ", liste.EnumerateArray()
+ .Select(e => $"{Text(e, "name")}: {Zahl(e, "anzahl")}"));
+ }
}
diff --git a/src/ClawdDotNet.Manager/InstanceRegistry.cs b/src/ClawdDotNet.Manager/InstanceRegistry.cs
index bfadf7b..2efef61 100644
--- a/src/ClawdDotNet.Manager/InstanceRegistry.cs
+++ b/src/ClawdDotNet.Manager/InstanceRegistry.cs
@@ -249,6 +249,15 @@ public sealed class InstanceRegistry : IAsyncDisposable
}
}
+ ///
+ /// Die Aufsicht über eine bestimmte Instanz, oder null.
+ ///
+ /// Damit sich ein Aufsichtsdurchgang von außen auslösen lässt, statt auf die Uhr
+ /// zu warten — Tests mit Wartezeiten sind Tests, die man irgendwann abschaltet.
+ ///
+ public InstanceSupervisor? SupervisorFor(string instanceDirectory)
+ => _supervisors.GetValueOrDefault(Path.GetFullPath(instanceDirectory));
+
private InstanceSupervisor Find(string instanceDirectory)
{
var pfad = Path.GetFullPath(instanceDirectory);
diff --git a/src/ClawdDotNet.Manager/InstanceSupervisor.cs b/src/ClawdDotNet.Manager/InstanceSupervisor.cs
index 9d00862..e0521d0 100644
--- a/src/ClawdDotNet.Manager/InstanceSupervisor.cs
+++ b/src/ClawdDotNet.Manager/InstanceSupervisor.cs
@@ -34,17 +34,37 @@ public enum InstanceCondition
/// die Aufsicht ihr Geld nicht mehr wert.
///
/// Wie lange eine Instanz nach der Bitte zu enden noch bekommt.
+///
+/// Wie lange eine verstummte Instanz nach der angeordneten Beendigung noch bekommt.
+///
+/// Der Bitte über den Socket ist bei einer Instanz, die eben auf demselben Socket
+/// nicht geantwortet hat, wenig zuzutrauen — sie kostet einen Versuch und wird deshalb
+/// unternommen, aber gewartet wird nicht auf sie, sondern auf die Uhr. Antwortet die
+/// Instanz in dieser Zeit wieder, war es ein Aussetzer und niemand wird abgeschossen.
+///
+///
+/// Die absolute Obergrenze beim Anhalten, wenn die Instanz meldet, dass sie aufräumt.
+///
+/// Muss größer sein als die Frist, die sich die Instanz selbst gibt
+/// (ShutdownCoordinator.Budget) — sonst schießt der Manager mitten in das
+/// Aufräumen hinein, um das er gerade gebeten hat. Und trotzdem endlich: Eine Instanz,
+/// die dauerhaft „ich räume noch auf" sagt, hängt genauso wie eine, die schweigt.
+///
public sealed record SupervisionOptions(
TimeSpan ProbeInterval,
TimeSpan StartTimeout,
int SilentChecksBeforeAlarm,
- TimeSpan StopGrace)
+ TimeSpan StopGrace,
+ TimeSpan SilentKillGrace,
+ TimeSpan MaxDrainWait)
{
public static SupervisionOptions Default { get; } = new(
ProbeInterval: TimeSpan.FromSeconds(15),
StartTimeout: TimeSpan.FromSeconds(60),
SilentChecksBeforeAlarm: 3,
- StopGrace: TimeSpan.FromSeconds(20));
+ StopGrace: TimeSpan.FromSeconds(20),
+ SilentKillGrace: TimeSpan.FromSeconds(30),
+ MaxDrainWait: TimeSpan.FromMinutes(5));
}
///
@@ -91,6 +111,9 @@ public sealed class InstanceSupervisor : IAsyncDisposable
private int _silentChecks;
private bool _stopRequested;
+ /// Seit wann das Verstummen andauert — der Beginn der Frist bis zum harten Ende.
+ private DateTimeOffset? _silentSince;
+
public InstanceSupervisor(
string instanceDirectory,
IInstanceLauncher launcher,
@@ -136,23 +159,58 @@ public sealed class InstanceSupervisor : IAsyncDisposable
///
public async Task StartAsync(CancellationToken ct = default)
{
- if (Condition == InstanceCondition.Running)
- {
- // Nicht dem eigenen Merkzettel glauben, sondern nachfragen. Zwischen zwei
- // Aufsichtsdurchgaengen liegen Sekunden, und in denen kann die Instanz weg
- // sein: Wer hier "laeuft schon" antwortet, meldet einen Erfolg, hinter dem
- // nichts steht - und der naechste Durchgang meldet dann den Absturz, den
- // niemand mehr erwartet.
- if (await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false) is not null)
- return true;
+ // Nicht dem eigenen Merkzettel glauben, sondern nachfragen - und zwar immer,
+ // nicht nur wenn die Aufsicht die Instanz fuer laufend haelt. Zwischen zwei
+ // Durchgaengen liegen Sekunden, und in denen kann sie ebenso wegbrechen wie
+ // wiederkommen. Wer hier ungefragt startet, setzt im zweiten Fall einen zweiten
+ // Prozess auf dieselbe state.db.
+ var vorhanden = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
- _logger.LogInformation(
- "Instanz {Name} galt als laufend, antwortet aber nicht mehr — sie wird gestartet.",
- InstanceName);
+ if (vorhanden is not null)
+ {
+ if (vorhanden.IsWindingDown)
+ {
+ // Sie faehrt gerade herunter. Jetzt zu starten hiesse, den neuen Prozess
+ // gegen den alten laufen zu lassen, waehrend der noch aufraeumt.
+ _logger.LogWarning(
+ "Instanz {Name} fährt gerade herunter ({Offen} offen) — es wird nicht "
+ + "gestartet, solange sie das tut.",
+ InstanceName, vorhanden.OpenSummary);
+
+ return false;
+ }
+
+ if (Condition != InstanceCondition.Running)
+ {
+ _logger.LogInformation(
+ "Instanz {Name} galt als {Zustand}, antwortet aber — sie wird übernommen "
+ + "statt gestartet.", InstanceName, Condition);
+ }
+
+ InstanceName = vorhanden.InstanceName;
+ Condition = InstanceCondition.Running;
+ _silentChecks = 0;
+ _silentSince = null;
+
+ return true;
+ }
+
+ // Ab hier antwortet niemand. Bevor gestartet wird, muss ein etwaiger alter
+ // Prozess nachweislich weg sein - genau hier entsteht sonst der Schaden, gegen
+ // den dieses ganze Vorhaben gebaut ist: zwei Prozesse auf einer Datenbank.
+ if (!await EnsureDeadAsync(ct).ConfigureAwait(false))
+ {
+ Raise(InstanceEventKind.StartFailed, _process?.Id, null,
+ $"Instanz {InstanceName} wird nicht gestartet: Der bisherige Prozess "
+ + $"{_process?.Id} lebt noch und war nicht zu beenden. Zwei Prozesse auf "
+ + "derselben Datenbank wären schlimmer als eine Instanz, die aus bleibt.");
+
+ return false;
}
_stopRequested = false;
_silentChecks = 0;
+ _silentSince = null;
try
{
@@ -207,6 +265,57 @@ public sealed class InstanceSupervisor : IAsyncDisposable
return true;
}
+ ///
+ /// Stellt sicher, dass der bisherige Prozess wirklich weg ist — notfalls mit Gewalt.
+ ///
+ /// Warum das eine eigene Prüfung ist. Kill() ist eine Bitte an das
+ /// Betriebssystem, keine vollzogene Tatsache: Der Aufruf kehrt zurück, bevor der
+ /// Prozess verschwunden ist, und bis dahin hält er die Datenbank und den Socket. Wer
+ /// unmittelbar danach startet, hat für einen Moment zwei Prozesse auf einer
+ /// state.db — und das ist kein Moment, sondern eine kaputte Datei.
+ ///
+ /// Ohne Prozesshandle gilt der Socket. Bei einer wiederaufgenommenen
+ /// Instanz gibt es nichts zu greifen (siehe ). Dann ist
+ /// die einzige verfügbare Auskunft, dass niemand mehr antwortet — und die muss
+ /// reichen, denn mehr ist nicht zu haben.
+ ///
+ /// true, wenn nachweislich nichts mehr läuft.
+ public async Task EnsureDeadAsync(CancellationToken ct = default)
+ {
+ if (_process is null)
+ {
+ // Kein Handle: Der Socket ist der einzige Zeuge. Wer hier gerufen wird, hat
+ // ihn gerade gefragt - aber ein zweites Mal fragen kostet nichts und deckt
+ // den Fall ab, dass zwischendurch jemand hochgekommen ist.
+ return await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false) is null;
+ }
+
+ if (_process.HasExited)
+ return true;
+
+ _logger.LogWarning(
+ "Prozess {Pid} von Instanz {Name} lebt noch — er wird beendet, bevor "
+ + "irgendetwas Neues startet.", _process.Id, InstanceName);
+
+ try { _process.Kill(); }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Prozess {Pid} liess sich nicht beenden.", _process.Id);
+ }
+
+ // Kill ist eine Bitte. Auf den Vollzug wird gewartet.
+ var deadline = _clock.GetUtcNow() + _options.SilentKillGrace;
+
+ while (_clock.GetUtcNow() < deadline)
+ {
+ if (_process.HasExited) return true;
+
+ await Task.Delay(TimeSpan.FromMilliseconds(250), _clock, ct).ConfigureAwait(false);
+ }
+
+ return _process.HasExited;
+ }
+
private async Task WaitForAnswerAsync(CancellationToken ct)
{
var deadline = _clock.GetUtcNow() + _options.StartTimeout;
@@ -324,6 +433,7 @@ public sealed class InstanceSupervisor : IAsyncDisposable
if (identity is not null)
{
_silentChecks = 0;
+ _silentSince = null;
if (Condition == InstanceCondition.Silent)
{
@@ -336,6 +446,17 @@ public sealed class InstanceSupervisor : IAsyncDisposable
Condition = InstanceCondition.Running;
}
+ if (identity.IsWindingDown)
+ {
+ // Kein Vorkommnis, sondern eine Auskunft: Sie tut, worum gebeten wurde.
+ // Ins Protokoll gehoert es trotzdem - hinterher will jemand nachlesen
+ // koennen, wie lange das Aufraeumen gedauert hat und woran.
+ _logger.LogInformation(
+ "Instanz {Name} fährt herunter, noch offen: {Offen}.",
+ InstanceName, string.IsNullOrEmpty(identity.OpenSummary)
+ ? "nichts" : identity.OpenSummary);
+ }
+
return;
}
@@ -364,16 +485,101 @@ public sealed class InstanceSupervisor : IAsyncDisposable
_silentChecks++;
if (Condition == InstanceCondition.Silent)
- return; // Schon gemeldet. Ein Alarm je Vorfall, nicht je Durchgang.
+ {
+ // Der Vorfall laeuft schon. Kein zweiter Alarm - aber die Uhr laeuft.
+ await EskalierenAsync(ct).ConfigureAwait(false);
+ return;
+ }
if (_silentChecks < _options.SilentChecksBeforeAlarm)
return;
Condition = InstanceCondition.Silent;
+ _silentSince = _clock.GetUtcNow();
Raise(InstanceEventKind.Unresponsive, _process?.Id, null,
$"Instanz {InstanceName} läuft, antwortet aber seit {_silentChecks} Durchgängen "
- + $"nicht mehr auf {InstanceEndpoint.Describe(InstanceDirectory)}.");
+ + $"nicht mehr auf {InstanceEndpoint.Describe(InstanceDirectory)}. Sie wird "
+ + $"gebeten zu enden; nach {_options.SilentKillGrace.TotalSeconds:0}s ohne "
+ + "Antwort wird der Prozess beendet.");
+
+ // Ein Versuch, mehr nicht. Wer eben auf demselben Socket nicht geantwortet hat,
+ // wird die Bitte wahrscheinlich auch nicht hoeren - aber "wahrscheinlich nicht"
+ // ist kein Grund, es zu lassen: Es kann ein einzelner haengender Endpunkt sein
+ // und nicht der ganze Prozess. Gewartet wird auf die Uhr, nicht auf die Antwort.
+ try
+ {
+ if (await _channel.RequestStopAsync(InstanceDirectory, ct).ConfigureAwait(false))
+ {
+ // Ausdruecklich ohne _stopRequested: Niemand hat gewollt, dass diese
+ // Instanz endet. Sie antwortet nicht, und was daraufhin passiert, ist ein
+ // Absturz und keine geordnete Beendigung - auch dann, wenn der letzte
+ // Anstoss von hier kam. Nur so greift der Autostart, und nur so steht
+ // hinterher im Protokoll, was wirklich vorgefallen ist.
+ _logger.LogInformation(
+ "Die Bitte zu enden ist bei der verstummten Instanz {Name} angekommen.",
+ InstanceName);
+ }
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogInformation(ex,
+ "Die Bitte zu enden erreichte die verstummte Instanz {Name} nicht.", InstanceName);
+ }
+ }
+
+ ///
+ /// Die Frist für eine verstummte Instanz läuft ab: Der Prozess wird beendet.
+ ///
+ /// Erst hier, nicht beim ersten Schweigen. Zwischen „antwortet gerade nicht" und
+ /// „ist hin" liegt ein Agentenlauf, der die Maschine auslastet — und ein Manager, der
+ /// dabei abschießt, ist schlimmer als gar keiner.
+ ///
+ private async Task EskalierenAsync(CancellationToken ct)
+ {
+ if (_silentSince is not { } seit) return;
+
+ var verstrichen = _clock.GetUtcNow() - seit;
+ if (verstrichen < _options.SilentKillGrace) return;
+
+ if (_process is null)
+ {
+ // Nicht zu greifen (siehe ReattachAsync). Einmal melden, dann Ruhe - sonst
+ // steht diese Zeile in jedem Durchgang bis in alle Ewigkeit.
+ _silentSince = null;
+
+ _logger.LogError(
+ "Instanz {Name} antwortet seit {Dauer:0}s nicht und ist von diesem Manager "
+ + "nicht zu beenden — es gibt keinen Prozess, den er greifen kann.",
+ InstanceName, verstrichen.TotalSeconds);
+
+ return;
+ }
+
+ _logger.LogWarning(
+ "Instanz {Name} antwortet seit {Dauer:0}s nicht — der Prozess wird beendet.",
+ InstanceName, verstrichen.TotalSeconds);
+
+ var pid = _process.Id;
+ var tot = await EnsureDeadAsync(ct).ConfigureAwait(false);
+
+ _silentSince = null;
+
+ if (!tot)
+ {
+ _logger.LogError(
+ "Prozess {Pid} von Instanz {Name} liess sich nicht beenden. Sie bleibt "
+ + "unter Aufsicht; gestartet wird nichts, solange er lebt.", pid, InstanceName);
+
+ return;
+ }
+
+ Condition = InstanceCondition.Crashed;
+ InstanceStateStore.Clear(InstanceDirectory);
+
+ Raise(InstanceEventKind.Crashed, pid, _process.ExitCode,
+ $"Instanz {InstanceName} hat {verstrichen.TotalSeconds:0}s nicht geantwortet "
+ + "und wurde beendet.");
}
/// Sieht von selbst nach, im eingestellten Takt.
@@ -424,17 +630,53 @@ public sealed class InstanceSupervisor : IAsyncDisposable
+ "es gilt trotzdem die Frist.", InstanceName);
}
- var deadline = _clock.GetUtcNow() + _options.StopGrace;
+ var begonnen = _clock.GetUtcNow();
+ var hartesEnde = begonnen + _options.MaxDrainWait;
+ var deadline = begonnen + _options.StopGrace;
+ var aufraeumenGemeldet = false;
while (_clock.GetUtcNow() < deadline)
{
- if (_process is { HasExited: true }
- || await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false) is null)
+ if (_process is { HasExited: true })
{
- Finish(_process?.ExitCode, gewaltsam: false);
+ Finish(_process.ExitCode, gewaltsam: false);
return true;
}
+ var puls = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
+
+ if (puls is null)
+ {
+ // Schweigen ist nur dann ein Beweis, wenn es keinen anderen Zeugen gibt.
+ // Haelt dieser Manager ein Prozesshandle und sagt es "lebt noch", dann
+ // lebt der Prozess - er antwortet bloss nicht mehr. Wer hier "beendet"
+ // meldet und anschliessend neu startet, setzt einen zweiten Prozess auf
+ // dieselbe state.db. Genau das war der Anlass dieses Vorhabens.
+ if (_process is null)
+ {
+ Finish(null, gewaltsam: false);
+ return true;
+ }
+ }
+ else if (puls.IsWindingDown)
+ {
+ // Sie raeumt auf - also tut sie genau das, worum gebeten wurde. Solange
+ // das laeuft, wird die Frist nachgezogen, aber nicht ins Unendliche:
+ // MaxDrainWait ist die Grenze, hinter der auch "ich raeume noch auf"
+ // nur noch eine andere Art zu haengen ist.
+ var neu = _clock.GetUtcNow() + _options.StopGrace;
+ deadline = neu < hartesEnde ? neu : hartesEnde;
+
+ if (!aufraeumenGemeldet)
+ {
+ aufraeumenGemeldet = true;
+
+ _logger.LogInformation(
+ "Instanz {Name} räumt auf ({Offen}) — es wird bis zu {Grenze:0}s gewartet.",
+ InstanceName, puls.OpenSummary, _options.MaxDrainWait.TotalSeconds);
+ }
+ }
+
await Task.Delay(TimeSpan.FromMilliseconds(250), _clock, ct).ConfigureAwait(false);
}
@@ -450,10 +692,18 @@ public sealed class InstanceSupervisor : IAsyncDisposable
}
_logger.LogWarning(
- "Instanz {Name} hat binnen {Frist}s nicht geendet — sie wird beendet.",
- InstanceName, _options.StopGrace.TotalSeconds);
+ "Instanz {Name} hat binnen {Frist:0}s nicht geendet — sie wird beendet.",
+ InstanceName, (_clock.GetUtcNow() - begonnen).TotalSeconds);
+
+ if (!await EnsureDeadAsync(ct).ConfigureAwait(false))
+ {
+ _logger.LogError(
+ "Prozess {Pid} von Instanz {Name} liess sich nicht beenden.",
+ _process.Id, InstanceName);
+
+ return false;
+ }
- _process.Kill();
Finish(_process.ExitCode, gewaltsam: true);
return true;
}
diff --git a/src/ClawdDotNet.Manager/ManagerCore.cs b/src/ClawdDotNet.Manager/ManagerCore.cs
index f8d4284..5c63e48 100644
--- a/src/ClawdDotNet.Manager/ManagerCore.cs
+++ b/src/ClawdDotNet.Manager/ManagerCore.cs
@@ -102,6 +102,10 @@ public sealed class ManagerCore : IAsyncDisposable
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);
};
diff --git a/src/ClawdDotNet.Manager/ManagerSettings.cs b/src/ClawdDotNet.Manager/ManagerSettings.cs
index b2501fd..ae5426e 100644
--- a/src/ClawdDotNet.Manager/ManagerSettings.cs
+++ b/src/ClawdDotNet.Manager/ManagerSettings.cs
@@ -130,6 +130,22 @@ public sealed class ManagerSettings
[JsonPropertyName("restartWindowMinutes")]
public int RestartWindowMinutes { get; set; } = 10;
+ ///
+ /// Wie lange eine verstummte Instanz nach der angeordneten Beendigung noch bekommt,
+ /// bevor der Prozess hart beendet wird.
+ ///
+ [JsonPropertyName("silentKillGraceSeconds")]
+ public int SilentKillGraceSeconds { get; set; } = 30;
+
+ ///
+ /// Die Obergrenze beim Anhalten, solange die Instanz meldet, dass sie aufräumt.
+ ///
+ /// Muss über der Frist liegen, die sich die Instanz selbst gibt — sonst schießt
+ /// der Manager in das Aufräumen hinein, um das er gebeten hat.
+ ///
+ [JsonPropertyName("maxDrainWaitSeconds")]
+ public int MaxDrainWaitSeconds { get; set; } = 300;
+
/// Die geprüfte Neustartregel.
public RestartPolicy ToRestartPolicy() => new(
MaxRestarts: Math.Clamp(MaxRestartsInWindow, 1, 100),
@@ -146,7 +162,9 @@ public sealed class ManagerSettings
ProbeInterval: TimeSpan.FromSeconds(Math.Clamp(ProbeIntervalSeconds, 1, 3600)),
StartTimeout: TimeSpan.FromSeconds(Math.Clamp(StartTimeoutSeconds, 5, 600)),
SilentChecksBeforeAlarm: Math.Clamp(SilentChecksBeforeAlarm, 1, 100),
- StopGrace: TimeSpan.FromSeconds(Math.Clamp(StopGraceSeconds, 1, 600)));
+ StopGrace: TimeSpan.FromSeconds(Math.Clamp(StopGraceSeconds, 1, 600)),
+ SilentKillGrace: TimeSpan.FromSeconds(Math.Clamp(SilentKillGraceSeconds, 1, 600)),
+ MaxDrainWait: TimeSpan.FromSeconds(Math.Clamp(MaxDrainWaitSeconds, 1, 3600)));
}
///
diff --git a/src/ClawdDotNet.ManagerApp/Program.cs b/src/ClawdDotNet.ManagerApp/Program.cs
index ba31792..24947d9 100644
--- a/src/ClawdDotNet.ManagerApp/Program.cs
+++ b/src/ClawdDotNet.ManagerApp/Program.cs
@@ -97,7 +97,24 @@ internal static class Program
return ExitNoWebUi;
}
- Console.WriteLine(web.Url);
+ Console.WriteLine();
+ Console.WriteLine("=================================================");
+ Console.WriteLine(" ClawdDotNet Instanzmanager bereit");
+ Console.WriteLine($" Weboberfläche: {web.Url}");
+ if (!string.IsNullOrWhiteSpace(settings.Settings.WebUiToken))
+ {
+ Console.WriteLine($" Token: {settings.Settings.WebUiToken}");
+ }
+ else
+ {
+ Console.WriteLine(" Token: (kein Token erforderlich)");
+ }
+ 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);
diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs
index 56ad5de..7d661ff 100644
--- a/src/ClawdDotNet.Web/Contracts/Dtos.cs
+++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs
@@ -175,3 +175,14 @@ public sealed record UsageDto(
/// Eine Protokollzeile.
public sealed record LogLineDto(string Module, string Level, string Text);
+
+/// Eine interne Kommunikation zwischen Agenten (A2A, #208).
+public sealed record AgentCommunicationDto(
+ string TargetAgentId,
+ string TargetAgentName,
+ string? SourceAgentId,
+ string? SourceAgentName,
+ string Role,
+ string Content,
+ DateTime Timestamp);
+
diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
index 150dc64..6114c75 100644
--- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
@@ -101,6 +101,58 @@ internal static class ApiEndpoints
.ToList();
});
+ // ─── Interne Agenten-Kommunikation (A2A, #208) ───
+ agents.MapGet("/communications", (AppHost host) =>
+ {
+ if (host.Engine is not { } engine)
+ return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
+
+ var allAgents = host.AgentAdmin.All;
+ var list = new List();
+
+ foreach (var agent in allAgents)
+ {
+ var history = engine.GetChatHistory(agent.AgentId);
+ foreach (var entry in history.Where(e => string.Equals(e.Source, Core.Engine.ChatSource.AgentComm, StringComparison.OrdinalIgnoreCase)))
+ {
+ string? senderId = null;
+ string? senderName = null;
+ var content = entry.Content;
+
+ if (entry.Role == "user" && content.StartsWith("[Nachricht von Agent \""))
+ {
+ var endQuote = content.IndexOf('"', 22);
+ if (endQuote > 22)
+ {
+ senderName = content[22..endQuote];
+ var openParen = content.IndexOf('(', endQuote);
+ var closeParen = content.IndexOf(')', openParen);
+ if (openParen > 0 && closeParen > openParen)
+ {
+ senderId = content[(openParen + 1)..closeParen];
+ }
+ }
+ var doubleNl = content.IndexOf("\n\n", StringComparison.Ordinal);
+ if (doubleNl > 0)
+ {
+ content = content[(doubleNl + 2)..];
+ }
+ }
+
+ list.Add(new AgentCommunicationDto(
+ TargetAgentId: agent.AgentId,
+ TargetAgentName: agent.DisplayName,
+ SourceAgentId: senderId,
+ SourceAgentName: senderName,
+ Role: entry.Role,
+ Content: content,
+ Timestamp: entry.Timestamp));
+ }
+ }
+
+ return Results.Ok(list.OrderBy(c => c.Timestamp).ToList());
+ });
+
agents.MapGet("/{agentId}", (AppHost host, string agentId) =>
host.AgentAdmin.Find(agentId) is { } agent
? Results.Ok(agent.ToDto(
diff --git a/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs
index 6dafca1..ae01b94 100644
--- a/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs
@@ -1,3 +1,5 @@
+using ClawdDotNet.App;
+using ClawdDotNet.Core.Instances;
using ClawdDotNet.Web.Security;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -5,6 +7,32 @@ using Microsoft.AspNetCore.Routing;
namespace ClawdDotNet.Web.Endpoints;
+/// Was noch offen ist, für die Puls-Auskunft.
+public sealed record OffeneArbeitDto(string Name, int Anzahl);
+
+///
+/// Der Puls einer Instanz: Wer bin ich, lebe ich, und arbeite ich noch an etwas?
+///
+/// Warum mehr als die Identität. Bis hierher fragte der Manager
+/// /api/info und wusste danach nur, dass überhaupt jemand geantwortet hat. Das
+/// reicht, um „läuft" von „tot" zu unterscheiden, aber nicht, um zwei Fälle
+/// auseinanderzuhalten, die völlig verschieden zu behandeln sind: eine Instanz, die
+/// hängt, und eine, die gerade geordnet aufräumt. Die erste gehört nach einer Frist
+/// abgeschossen, die zweite in Ruhe gelassen.
+///
+/// Die Kennung — der eigentliche Beweis, wer da antwortet.
+/// "Running", "Draining" oder "Done".
+/// Was gerade noch läuft. Leer heißt: nichts.
+/// Wie lange das Auslaufen noch dauern darf.
+public sealed record PulsDto(
+ string InstanceId,
+ string InstanceName,
+ string AppVersion,
+ string Phase,
+ IReadOnlyList OffeneArbeiten,
+ int OffenGesamt,
+ double RestsekundenFrist);
+
///
/// Was der Instanzmanager diesem Prozess sagen kann — und was der Prozess damit macht.
///
@@ -64,6 +92,25 @@ internal static class ControlEndpoints
{
var control = app.MapGroup("/api/instanz");
+ // Der Puls. Nur ueber den Socket - was hier steht, geht ausschliesslich den
+ // Instanzmanager etwas an, und er ist der Einzige, der diesen Weg hat.
+ control.MapGet("/puls", (HttpContext context, AppHost host) =>
+ {
+ if (!LocalTransport.Applies(context))
+ return Results.NotFound();
+
+ var stand = host.Shutdown.Snapshot();
+
+ return Results.Ok(new PulsDto(
+ host.Instance.InstanceId,
+ host.Instance.InstanceName,
+ AppHost.AppVersion,
+ stand.Phase.ToString(),
+ [.. stand.Open.Select(o => new OffeneArbeitDto(o.Name, o.Count))],
+ stand.TotalOpen,
+ Math.Round(stand.Remaining.TotalSeconds, 1)));
+ });
+
control.MapPost("/beenden", (HttpContext context, InstanceControl steuerung) =>
{
if (!LocalTransport.Applies(context))
diff --git a/tests/ClawdDotNet.Core.Tests/Instances/ShutdownCoordinatorTests.cs b/tests/ClawdDotNet.Core.Tests/Instances/ShutdownCoordinatorTests.cs
new file mode 100644
index 0000000..1f88add
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Instances/ShutdownCoordinatorTests.cs
@@ -0,0 +1,160 @@
+using ClawdDotNet.Core.Instances;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Instances;
+
+///
+/// Das geordnete Ende einer Instanz (#528).
+///
+/// Worum es geht. Bis hierher war jedes Beenden ein Abschneiden. Eine
+/// Aufgabe, die mitten in der Ausführung abgebrochen wird, ist bereits beansprucht und
+/// noch nicht abgeschlossen — sie steht danach auf running und bleibt dort bis zum
+/// Ablauf ihres Lease. Ein Agentenlauf verliert seine Antwort ganz.
+///
+public sealed class ShutdownCoordinatorTests
+{
+ private static ShutdownCoordinator Koordinator(TimeSpan? frist = null)
+ => new(NullLogger.Instance, frist ?? TimeSpan.FromSeconds(5));
+
+ ///
+ /// Die Reihenfolge ist der ganze Trick: erst schließen, dann warten.
+ ///
+ /// Andersherum wartet man ewig — die Quelle nähme im nächsten Takt die nächste
+ /// Arbeit an, und die Zahl der offenen ginge nie auf null.
+ ///
+ [Fact]
+ public async Task Erst_wird_die_Tuer_geschlossen_dann_gewartet()
+ {
+ var geschlossen = false;
+ var offenBeimSchliessen = -1;
+ var offen = 3;
+
+ var koordinator = Koordinator();
+
+ koordinator.Register(new WorkSource(
+ "Aufgaben",
+ () => offen,
+ () =>
+ {
+ geschlossen = true;
+ offenBeimSchliessen = offen;
+ }));
+
+ // Die Arbeiten laufen im Hintergrund aus.
+ _ = Task.Run(async () =>
+ {
+ while (offen > 0)
+ {
+ await Task.Delay(30);
+ offen--;
+ }
+ });
+
+ await koordinator.BeginAsync();
+
+ geschlossen.ShouldBeTrue();
+ offenBeimSchliessen.ShouldBe(3, "Geschlossen wird, bevor gewartet wird.");
+ koordinator.Phase.ShouldBe(ShutdownPhase.Done);
+ koordinator.Snapshot().TotalOpen.ShouldBe(0);
+ }
+
+ ///
+ /// Ohne offene Arbeit endet es sofort — kein Warten aus Prinzip.
+ ///
+ [Fact]
+ public async Task Ohne_offene_Arbeit_endet_es_sofort()
+ {
+ var koordinator = Koordinator(TimeSpan.FromMinutes(10));
+ koordinator.Register(new WorkSource("Aufgaben", () => 0, () => { }));
+
+ var begonnen = DateTimeOffset.UtcNow;
+ await koordinator.BeginAsync();
+
+ (DateTimeOffset.UtcNow - begonnen).ShouldBeLessThan(TimeSpan.FromSeconds(2));
+ koordinator.Phase.ShouldBe(ShutdownPhase.Done);
+ }
+
+ ///
+ /// Die Frist ist eine Zusage, keine Hoffnung.
+ ///
+ /// Sonst hinge die Instanz an einem Agentenlauf, der auf ein Modell wartet, das
+ /// nicht antwortet — und niemand käme je zum Ende.
+ ///
+ [Fact]
+ public async Task Nach_der_Frist_endet_es_auch_mit_offener_Arbeit()
+ {
+ var koordinator = Koordinator(TimeSpan.FromMilliseconds(300));
+ koordinator.Register(new WorkSource("Agentenläufe", () => 2, () => { }));
+
+ await koordinator.BeginAsync();
+
+ koordinator.Phase.ShouldBe(ShutdownPhase.Done);
+ }
+
+ ///
+ /// Während des Auslaufens sagt der Puls, was noch offen ist — daran erkennt der
+ /// Instanzmanager, dass hier aufgeräumt und nicht gehangen wird.
+ ///
+ [Fact]
+ public async Task Waehrend_des_Auslaufens_meldet_der_Stand_was_offen_ist()
+ {
+ var offen = 2;
+ var koordinator = Koordinator(TimeSpan.FromSeconds(5));
+ koordinator.Register(new WorkSource("Agentenläufe", () => offen, () => { }));
+
+ koordinator.Snapshot().Phase.ShouldBe(ShutdownPhase.Running);
+
+ var lauf = koordinator.BeginAsync();
+
+ // Solange etwas offen ist, steht der Stand auf Draining und nennt die Zahl.
+ var stand = koordinator.Snapshot();
+ stand.Phase.ShouldBe(ShutdownPhase.Draining);
+ stand.TotalOpen.ShouldBe(2);
+ stand.Open.Single().Name.ShouldBe("Agentenläufe");
+
+ offen = 0;
+ await lauf;
+
+ koordinator.Snapshot().Phase.ShouldBe(ShutdownPhase.Done);
+ }
+
+ ///
+ /// Zwei Aufrufer, ein Vorgang.
+ ///
+ /// Beide Wege gibt es wirklich: Der Manager bittet über den Socket, und
+ /// gleichzeitig kann jemand am Fenster auf das Kreuz klicken. Zweimal auszulaufen
+ /// hieße, die Tür zweimal zu schließen und zweimal zu warten.
+ ///
+ [Fact]
+ public async Task Ein_zweiter_Aufruf_wartet_auf_denselben_Vorgang()
+ {
+ var schliessungen = 0;
+ var koordinator = Koordinator(TimeSpan.FromSeconds(5));
+ koordinator.Register(new WorkSource("Aufgaben", () => 0, () => schliessungen++));
+
+ await Task.WhenAll(koordinator.BeginAsync(), koordinator.BeginAsync());
+
+ schliessungen.ShouldBe(1);
+ }
+
+ ///
+ /// Eine Quelle, die beim Zählen stolpert, darf das Ende nicht aufhalten.
+ ///
+ [Fact]
+ public async Task Eine_stolpernde_Quelle_haelt_das_Ende_nicht_auf()
+ {
+ var koordinator = Koordinator(TimeSpan.FromSeconds(30));
+
+ koordinator.Register(new WorkSource(
+ "Kaputt",
+ () => throw new InvalidOperationException("zählt nicht"),
+ () => throw new InvalidOperationException("schließt nicht")));
+
+ var begonnen = DateTimeOffset.UtcNow;
+ await koordinator.BeginAsync();
+
+ (DateTimeOffset.UtcNow - begonnen).ShouldBeLessThan(TimeSpan.FromSeconds(2));
+ koordinator.Phase.ShouldBe(ShutdownPhase.Done);
+ }
+}
diff --git a/tests/ClawdDotNet.Manager.Tests/AutostartTests.cs b/tests/ClawdDotNet.Manager.Tests/AutostartTests.cs
index ae78084..b9c9f2a 100644
--- a/tests/ClawdDotNet.Manager.Tests/AutostartTests.cs
+++ b/tests/ClawdDotNet.Manager.Tests/AutostartTests.cs
@@ -32,7 +32,9 @@ public sealed class AutostartTests : IAsyncDisposable
ProbeInterval: TimeSpan.FromMilliseconds(50),
StartTimeout: TimeSpan.FromMilliseconds(600),
SilentChecksBeforeAlarm: 3,
- StopGrace: TimeSpan.FromMilliseconds(300));
+ StopGrace: TimeSpan.FromMilliseconds(300),
+ SilentKillGrace: TimeSpan.FromMilliseconds(300),
+ MaxDrainWait: TimeSpan.FromSeconds(2));
public AutostartTests()
{
@@ -175,6 +177,87 @@ public sealed class AutostartTests : IAsyncDisposable
_launcher.Starts.ShouldBe(1, "Nur der Start von Hand, kein Neustart.");
}
+ ///
+ /// Eine verstummte Instanz wird beendet und neu gestartet — aber erst, nachdem der
+ /// alte Prozess nachweislich weg ist.
+ ///
+ /// Der Fall wird hier wirklich hergestellt und nicht behauptet. Der
+ /// Prozess lebt weiter, nur der Socket schweigt — genau die Lage aus #529. Ein Test,
+ /// der stattdessen das Ereignis von Hand baut und die Neustartmethode aufruft, prüft
+ /// die eine Hälfte, die ohnehin funktioniert, und lässt die gefährliche aus: dass vor
+ /// dem Start niemand mehr auf der Datenbank sitzt.
+ ///
+ [Fact]
+ public async Task Eine_verstummte_Instanz_wird_erst_beendet_und_dann_neu_gestartet()
+ {
+ var pfad = Instanz("Alpha", autostart: true);
+
+ await using var manager = Manager();
+ await manager.StartAsync();
+
+ var alt = _launcher.Last.ShouldNotBeNull();
+ _launcher.Starts.ShouldBe(1);
+
+ // Ab jetzt haengt sie: Der Prozess lebt, der Socket antwortet nicht mehr.
+ _kanal.Verstummen(pfad);
+
+ var aufsicht = manager.Instances.SupervisorFor(pfad).ShouldNotBeNull();
+
+ // Drei Durchgaenge bis zum Alarm - vorher wird nichts unternommen.
+ for (var i = 0; i < 3; i++) await aufsicht.CheckAsync();
+
+ aufsicht.Condition.ShouldBe(InstanceCondition.Silent);
+ alt.WasKilled.ShouldBeFalse("Vor Ablauf der Frist wird nicht abgeschossen.");
+
+ // Die Frist laeuft ab (SilentKillGrace: 300ms).
+ await Task.Delay(400);
+ await aufsicht.CheckAsync();
+
+ alt.WasKilled.ShouldBeTrue("Nach der Frist wird der haengende Prozess beendet.");
+
+ // Der Absturzweg startet neu - und der neue Prozess ist ein anderer.
+ await Warte(() => _launcher.Starts >= 2);
+
+ _launcher.Last.ShouldNotBeNull().Id.ShouldNotBe(alt.Id);
+ alt.HasExited.ShouldBeTrue("Der alte Prozess darf beim Start nicht mehr leben.");
+ }
+
+ ///
+ /// Eine Instanz, die aufräumt, wird nicht abgeschossen — auch wenn es dauert.
+ ///
+ /// Sie antwortet ja; sie tut genau das, worum gebeten wurde. Der Unterschied
+ /// zwischen „hängt" und „räumt auf" ist der ganze Grund, warum der Puls mehr sagt als
+ /// „jemand hat geantwortet".
+ ///
+ [Fact]
+ public async Task Wer_aufraeumt_gilt_nicht_als_verstummt()
+ {
+ var pfad = Instanz("Alpha", autostart: true);
+
+ await using var manager = Manager();
+ await manager.StartAsync();
+
+ var prozess = _launcher.Last.ShouldNotBeNull();
+
+ _kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity(
+ "i-Alpha", "Alpha", "0.1.2",
+ ShutdownPhase.Draining, OpenWork: 2, OpenSummary: "Agentenläufe: 2");
+
+ var aufsicht = manager.Instances.SupervisorFor(pfad).ShouldNotBeNull();
+
+ for (var i = 0; i < 5; i++) await aufsicht.CheckAsync();
+
+ aufsicht.Condition.ShouldBe(InstanceCondition.Running);
+ prozess.WasKilled.ShouldBeFalse();
+ _launcher.Starts.ShouldBe(1);
+ }
+
+ private static async Task Warte(Func bedingung)
+ {
+ for (var i = 0; i < 100 && !bedingung(); i++)
+ await Task.Delay(20);
+ }
+
///
/// Nach der Grenze setzt der Manager aus.
///
@@ -341,7 +424,9 @@ public sealed class StaleStateTests : IAsyncDisposable
TimeSpan.FromSeconds(30), // absichtlich traege: kein Durchgang dazwischen
TimeSpan.FromMilliseconds(600),
3,
- TimeSpan.FromMilliseconds(300)));
+ TimeSpan.FromMilliseconds(300),
+ TimeSpan.FromMilliseconds(300),
+ TimeSpan.FromSeconds(2)));
(await aufsicht.StartAsync()).ShouldBeTrue();
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
diff --git a/tests/ClawdDotNet.Manager.Tests/BackupManagementTests.cs b/tests/ClawdDotNet.Manager.Tests/BackupManagementTests.cs
new file mode 100644
index 0000000..97635e0
--- /dev/null
+++ b/tests/ClawdDotNet.Manager.Tests/BackupManagementTests.cs
@@ -0,0 +1,96 @@
+using ClawdDotNet.Core.Backup;
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Instances;
+using Shouldly;
+
+namespace ClawdDotNet.Manager.Tests;
+
+///
+/// Tests für Sicherung und Wiederherstellung im Manager (#530, Stufe 6).
+///
+public sealed class BackupManagementTests : IDisposable
+{
+ private readonly string _root = Path.Combine(
+ Path.GetTempPath(), "clawd-tests", "backup-" + Guid.NewGuid().ToString("N")[..8]);
+
+ public BackupManagementTests()
+ {
+ Directory.CreateDirectory(_root);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_root, recursive: true); }
+ catch { }
+ }
+
+ [Fact]
+ public void BackupDirectory_wird_standardmaessig_unter_Backups_aufgeloest()
+ {
+ var instancePath = Path.Combine(_root, "TestInstance");
+ Directory.CreateDirectory(instancePath);
+
+ var config = new InstanceConfig { WorkingDirectory = instancePath };
+ var backupDir = InstanceLayout.ResolveBackupDirectory(config, instancePath);
+
+ backupDir.ShouldBe(Path.Combine(instancePath, "Backups"));
+ }
+
+ [Fact]
+ public async Task Backup_kann_erstellt_geprueft_und_wiederhergestellt_werden()
+ {
+ var instancePath = Path.Combine(_root, "InstanceAlpha");
+ Directory.CreateDirectory(instancePath);
+
+ var settingsPath = Path.Combine(instancePath, "InstanceSettings.json");
+ await File.WriteAllTextAsync(settingsPath, """
+ {
+ "instanceId": "alpha-123",
+ "instanceName": "Alpha",
+ "workingDirectory": ""
+ }
+ """);
+
+ var testDataPath = Path.Combine(instancePath, "data.txt");
+ await File.WriteAllTextAsync(testDataPath, "Wichtige Testdaten");
+
+ var backupDir = InstanceLayout.ResolveBackupDirectory(
+ new InstanceConfig { WorkingDirectory = instancePath }, instancePath);
+ Directory.CreateDirectory(backupDir);
+
+ var zipPath = Path.Combine(backupDir, "backup_Alpha_test.zip");
+
+ var service = new BackupService();
+ var backupResult = await service.CreateAsync(instancePath, zipPath, new BackupOptions
+ {
+ Secrets = SecretMode.Exclude,
+ IncludeChatHistory = true,
+ IncludeLogs = false
+ });
+
+ File.Exists(zipPath).ShouldBeTrue();
+ backupResult.SizeBytes.ShouldBeGreaterThan(0);
+
+ // Prüfen
+ var manifest = await service.InspectAsync(zipPath);
+ manifest.InstanceId.ShouldBe("alpha-123");
+ manifest.InstanceName.ShouldBe("Alpha");
+ manifest.Files.Count.ShouldBeGreaterThan(0);
+
+ // Datei im Ziel modifizieren und Restore prüfen
+ await File.WriteAllTextAsync(testDataPath, "Überschriebene Testdaten");
+
+ var restoreResult = await service.RestoreAsync(
+ zipPath,
+ instancePath,
+ new RestoreOptions { Overwrite = true });
+
+ restoreResult.Written.ShouldContain("data.txt");
+ var restoredContent = await File.ReadAllTextAsync(testDataPath);
+ restoredContent.ShouldBe("Wichtige Testdaten");
+
+ // Löschen
+ File.Delete(zipPath);
+ File.Exists(zipPath).ShouldBeFalse();
+ }
+}
diff --git a/tests/ClawdDotNet.Manager.Tests/Doubles.cs b/tests/ClawdDotNet.Manager.Tests/Doubles.cs
index 780893a..f514947 100644
--- a/tests/ClawdDotNet.Manager.Tests/Doubles.cs
+++ b/tests/ClawdDotNet.Manager.Tests/Doubles.cs
@@ -76,6 +76,20 @@ internal sealed class FakeLauncher : IInstanceLauncher
return Last;
}
+ ///
+ /// Ein Prozess, der schon lief, bevor dieser Manager kam (E-M6).
+ ///
+ /// Zählt nicht als Start — genau darin liegt der Unterschied, den mehrere Tests
+ /// prüfen: Was läuft, wird übernommen und nicht ein zweites Mal hochgefahren.
+ ///
+ public FakeProcess Fremdstart(string instanceDirectory, int processId)
+ {
+ var prozess = new FakeProcess(processId, Now);
+ Running[processId] = prozess;
+ ByDirectory[Path.GetFullPath(instanceDirectory)] = prozess;
+ return prozess;
+ }
+
public IInstanceProcess? Attach(int processId, DateTimeOffset startedAt)
{
if (!Running.TryGetValue(processId, out var process))
@@ -120,6 +134,20 @@ internal sealed class FakeChannel : IInstanceChannel
///
public FakeLauncher? Launcher { get; set; }
+ ///
+ /// Verzeichnisse, deren Instanz verstummt ist: Der Prozess lebt, der Socket schweigt.
+ ///
+ /// Der Fall aus #529 — und der einzige, in dem die Aufsicht von sich aus zur
+ /// Gewalt greift. Ohne diesen Schalter lässt er sich nicht nachstellen: Die Kopplung
+ /// an bindet die Antwort sonst genau an das, was hier
+ /// auseinanderfallen soll.
+ ///
+ public HashSet Verstummt { get; } = new(StringComparer.OrdinalIgnoreCase);
+
+ /// Die Instanz hört auf zu antworten, ohne dass ihr Prozess endet.
+ public void Verstummen(string instanceDirectory)
+ => Verstummt.Add(Path.GetFullPath(instanceDirectory));
+
public int Calls { get; private set; }
/// Wie oft die Instanz gebeten wurde, sich zu beenden.
@@ -132,6 +160,9 @@ internal sealed class FakeChannel : IInstanceChannel
{
Calls++;
+ if (Verstummt.Contains(Path.GetFullPath(instanceDirectory)))
+ return Task.FromResult(null);
+
if (Launcher is not null && !Launcher.IsRunning(instanceDirectory))
return Task.FromResult(null);
diff --git a/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs b/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs
index 76c90ee..076ff3b 100644
--- a/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs
+++ b/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs
@@ -27,10 +27,17 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
ProbeInterval: TimeSpan.FromMilliseconds(50),
StartTimeout: TimeSpan.FromMilliseconds(600),
SilentChecksBeforeAlarm: 3,
- StopGrace: TimeSpan.FromMilliseconds(300));
+ StopGrace: TimeSpan.FromMilliseconds(300),
+ SilentKillGrace: TimeSpan.FromMilliseconds(300),
+ MaxDrainWait: TimeSpan.FromSeconds(2));
public InstanceRegistryTests()
- => _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
+ {
+ _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
+ // Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
+ // Betrieb (siehe FakeChannel.Launcher).
+ _kanal.Launcher = _launcher;
+ }
private InstanceRegistry Register()
{
diff --git a/tests/ClawdDotNet.Manager.Tests/InstanceSupervisorTests.cs b/tests/ClawdDotNet.Manager.Tests/InstanceSupervisorTests.cs
index c614da2..c2dca95 100644
--- a/tests/ClawdDotNet.Manager.Tests/InstanceSupervisorTests.cs
+++ b/tests/ClawdDotNet.Manager.Tests/InstanceSupervisorTests.cs
@@ -1,3 +1,4 @@
+using ClawdDotNet.Core.Instances;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
@@ -38,9 +39,19 @@ public sealed class InstanceSupervisorTests : IDisposable
ProbeInterval: TimeSpan.FromMilliseconds(50),
StartTimeout: TimeSpan.FromMilliseconds(600),
SilentChecksBeforeAlarm: 3,
- StopGrace: TimeSpan.FromMilliseconds(300));
+ StopGrace: TimeSpan.FromMilliseconds(300),
+ SilentKillGrace: TimeSpan.FromMilliseconds(300),
+ MaxDrainWait: TimeSpan.FromSeconds(2));
- public InstanceSupervisorTests() => Directory.CreateDirectory(_dir);
+ public InstanceSupervisorTests()
+ {
+ Directory.CreateDirectory(_dir);
+
+ // Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
+ // Betrieb. Eine vorab hinterlegte Antwort hiesse sonst "laeuft schon", und die
+ // Aufsicht wuerde uebernehmen statt zu starten.
+ _kanal.Launcher = _launcher;
+ }
public void Dispose()
{
@@ -252,6 +263,72 @@ public sealed class InstanceSupervisorTests : IDisposable
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
}
+ ///
+ /// Schweigen ist kein Todesnachweis, solange ein Prozesshandle das Gegenteil sagt.
+ ///
+ /// Hier lag der gefährlichste Fehlschluss. Die Warteschleife brach ab,
+ /// sobald der Socket nicht mehr antwortete, meldete „beendet" und gab true
+ /// zurück — ohne den Prozess anzufassen. Bei einer verstummten Instanz ist der Socket
+ /// aber von vornherein still: Der Aufruf meldete sofort Erfolg, der Prozess lief
+ /// weiter, und der Neustart setzte einen zweiten auf dieselbe state.db. Genau
+ /// der Schaden, gegen den dieses Vorhaben gebaut ist.
+ ///
+ [Fact]
+ public async Task Ein_stummer_aber_lebender_Prozess_gilt_nicht_als_beendet()
+ {
+ _kanal.Answer = Test;
+
+ await using var aufsicht = Aufsicht();
+ await aufsicht.StartAsync();
+
+ var prozess = _launcher.Last.ShouldNotBeNull();
+
+ // Sie haengt: Der Socket schweigt, der Prozess lebt.
+ _kanal.Verstummen(_dir);
+
+ (await aufsicht.StopAsync()).ShouldBeTrue();
+
+ prozess.WasKilled.ShouldBeTrue(
+ "Wer schweigt und lebt, muss beendet werden - nicht fuer beendet erklaert.");
+
+ prozess.HasExited.ShouldBeTrue();
+ aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
+ }
+
+ ///
+ /// Wer aufräumt, wird nicht abgeschossen — die Frist wird nachgezogen.
+ ///
+ /// Die Instanz gibt sich selbst eine Frist zum Auslaufen. Wäre die des Managers
+ /// kürzer, schösse er mitten in das Aufräumen hinein, um das er gerade gebeten
+ /// hat.
+ ///
+ [Fact]
+ public async Task Wer_aufraeumt_bekommt_die_Frist_nachgezogen()
+ {
+ _kanal.Answer = Test;
+
+ await using var aufsicht = Aufsicht();
+ await aufsicht.StartAsync();
+
+ var prozess = _launcher.Last.ShouldNotBeNull();
+
+ // Sie antwortet und meldet: Ich raeume auf. Laenger als StopGrace (300ms), aber
+ // kuerzer als MaxDrainWait (2s).
+ _kanal.Answer = Test with { Phase = ShutdownPhase.Draining, OpenWork = 1 };
+
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(700);
+ _kanal.Answer = null;
+ prozess.Exit(0);
+ });
+
+ (await aufsicht.StopAsync()).ShouldBeTrue();
+
+ prozess.WasKilled.ShouldBeFalse("Wer aufraeumt, wird nicht abgeschossen.");
+ aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
+ }
+
///
/// Eine Instanz, die der Manager nicht selbst gestartet hat und die nicht hört, kann
/// er auch nicht beenden — und das muss er sagen, statt Erfolg zu melden.
@@ -265,6 +342,10 @@ public sealed class InstanceSupervisorTests : IDisposable
_kanal.Answer = Test;
_kanal.StopRequestArrives = false;
+ // Sie lief schon, bevor dieser Manager kam - und ohne Zustandsdatei ist ihr
+ // Prozess fuer ihn nicht zu greifen.
+ _launcher.Fremdstart(_dir, 4711);
+
await using var aufsicht = Aufsicht();
await aufsicht.ReattachAsync(); // uebernommen, aber ohne Prozess
aufsicht.ProcessId.ShouldBeNull();
@@ -356,6 +437,7 @@ public sealed class InstanceSupervisorTests : IDisposable
// Der Fall nach einem Absturz des Managers, bevor er schreiben konnte - oder
// nachdem jemand die Datei geloescht hat. Der Socket antwortet, also laeuft sie.
_kanal.Answer = Test;
+ _launcher.Fremdstart(_dir, 4711);
await using var aufsicht = Aufsicht();
(await aufsicht.ReattachAsync()).ShouldBeTrue();
diff --git a/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs
index 2d1cee2..ad22252 100644
--- a/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs
+++ b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs
@@ -41,12 +41,17 @@ public sealed class ManagerCoreTests : IAsyncDisposable
ProbeInterval: TimeSpan.FromMilliseconds(50),
StartTimeout: TimeSpan.FromMilliseconds(600),
SilentChecksBeforeAlarm: 3,
- StopGrace: TimeSpan.FromMilliseconds(300));
+ StopGrace: TimeSpan.FromMilliseconds(300),
+ SilentKillGrace: TimeSpan.FromMilliseconds(300),
+ MaxDrainWait: TimeSpan.FromSeconds(2));
public ManagerCoreTests()
{
Directory.CreateDirectory(_root);
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
+ // Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
+ // Betrieb (siehe FakeChannel.Launcher).
+ _kanal.Launcher = _launcher;
}
private ManagerCore Manager()
@@ -239,12 +244,17 @@ public sealed class InstanceFocusTests : IAsyncDisposable
ProbeInterval: TimeSpan.FromMilliseconds(50),
StartTimeout: TimeSpan.FromMilliseconds(600),
SilentChecksBeforeAlarm: 3,
- StopGrace: TimeSpan.FromMilliseconds(300));
+ StopGrace: TimeSpan.FromMilliseconds(300),
+ SilentKillGrace: TimeSpan.FromMilliseconds(300),
+ MaxDrainWait: TimeSpan.FromSeconds(2));
public InstanceFocusTests()
{
Directory.CreateDirectory(_root);
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
+ // Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
+ // Betrieb (siehe FakeChannel.Launcher).
+ _kanal.Launcher = _launcher;
}
private InstanceRegistry Register() => new(
diff --git a/tests/ClawdDotNet.Web.Tests/AgentCommunicationTests.cs b/tests/ClawdDotNet.Web.Tests/AgentCommunicationTests.cs
new file mode 100644
index 0000000..5ced8b6
--- /dev/null
+++ b/tests/ClawdDotNet.Web.Tests/AgentCommunicationTests.cs
@@ -0,0 +1,32 @@
+using ClawdDotNet.Web.Contracts;
+using Shouldly;
+
+namespace ClawdDotNet.Web.Tests;
+
+///
+/// Tests für die interne Agenten-Kommunikation A2A (#208).
+///
+public sealed class AgentCommunicationTests
+{
+ [Fact]
+ public void AgentCommunicationDto_speichert_Felder_korrekt()
+ {
+ var now = DateTime.UtcNow;
+ var dto = new AgentCommunicationDto(
+ TargetAgentId: "editor-1",
+ TargetAgentName: "Editor",
+ SourceAgentId: "scout-1",
+ SourceAgentName: "Scout",
+ Role: "user",
+ Content: "Bitte bearbeite Datei X",
+ Timestamp: now);
+
+ dto.TargetAgentId.ShouldBe("editor-1");
+ dto.TargetAgentName.ShouldBe("Editor");
+ dto.SourceAgentId.ShouldBe("scout-1");
+ dto.SourceAgentName.ShouldBe("Scout");
+ dto.Role.ShouldBe("user");
+ dto.Content.ShouldBe("Bitte bearbeite Datei X");
+ dto.Timestamp.ShouldBe(now);
+ }
+}