Mehrere Instanzen sollen unabhaengig voneinander laufen. Heute tun sie das nicht: Sie teilen sich Protokollverzeichnis, Sicherungsordner und Sicherungszeitplan. In einer gemeinsamen Startup.log standen abwechselnd Zeilen zweier Instanzen, ohne Kennzeichnung, welche gerade spricht -- im Mehrinstanzbetrieb ist damit jede Fehlersuche Ratearbeit. Genau in dieser Lage ist der Aufhaenger aus #529 entstanden. Fuenf Schluessel wandern aus AppSettings nach InstanceSettings.json: logDirectory, backupDirectory, backupKeepCount, autoBackupEnabled, autoBackupTime. Leer heisst jetzt <Instanz>/Logs beziehungsweise <Instanz>/Backups; ein relativer Wert zaehlt weiterhin gegen das Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses (#375 bleibt gewahrt). autoBackupEnabled und autoBackupTime standen in der Planung nicht, sind aber mitgegangen: Ein Schalter, der die taegliche Sicherung fuer eine Instanz einschaltet und sie damit fuer alle einschaltet, ist derselbe Fehler wie ein Ordner fuer alle -- und zwei von vier Sicherungsfeldern zu verschieben hinterliesse eine Einstellungsseite, auf der die Haelfte anwendungsweit gilt. Keine Uebernahme der Altwerte, aber eine Ansage: SettingsManager. AbandonedKeys meldet die aufgegebenen Schluessel, solange sie noch in Settings.json stehen, und der Start protokolliert einmal, was nicht mehr gilt und wohin diese Instanz stattdessen schreibt. Ein anwendungsweiter Pfad, in alle Instanzen kopiert, waere wieder ein Ordner fuer alle. webServerPort bleibt vorerst stehen. Es entfaellt mit Stufe 2, sobald Kestrel auf dem Socket hoert; vorher wegzunehmen hiesse, die Weboberflaeche ohne Ersatz abzuschalten. Korrektur an der eigenen Befundlage: "Instanz A wirft die Sicherungen von Instanz B weg" war zu breit. Beide Rotationen filtern bereits nach dem Praefix backup_<Instanzname>_. Der reale Defekt ist enger, und ein Praefix ist keine Grenze: backup_Test_2_*.zip faengt mit backup_Test_ an, also zaehlt Test im gemeinsamen Ordner die Archive von Test_2 mit. Belegt in BackupSchedulerTests, das den alten Zustand eigens wiederherstellt. Nachgeprueft an der laufenden Installation, nicht nur im Test: Nach dem Umbau schreibt Instance-Test nach Instances/Instance-Test/Logs, das alte gemeinsame Verzeichnis bekommt keine Zeile mehr, und die Warnung zu den aufgegebenen Schluesseln erscheint genau einmal. Enthaelt ausserdem, was seit der Weboberflaeche liegengeblieben war: - #526: Instanzgeheimnisse wurden beim Speichern verschluesselt und beim Laden nie entschluesselt. LoadInstanceConfig hatte kein ConfigSecrets.Unprotect; ConfigLoader.LoadAsync macht es richtig, wird zur Laufzeit aber gar nicht aufgerufen. Der OpenRouter-Schluessel ging so verschluesselt an die API. - Ein offener Ereignisstrom hielt das Beenden fuenf Sekunden auf. Die Stroeme hingen nur an RequestAborted, das beim geordneten Herunterfahren nicht feuert; jetzt zusaetzlich an ApplicationStopping. Der Fang in WebUiHost gilt dazu ohne Bedingung auf RequestAborted -- mit ihr waere jeder offene Strom beim Beenden als Fehler im Bugtracker gelandet. - Nach der Anmeldung blieb die Weboberflaeche leer: #anmeldung { display:flex } schlaegt als ID-Selektor das hidden-Attribut. - docs/Instanzmanager-Konzept.md: Befund, die Entscheidungen E-M1 bis E-M6, der Schnitt zwischen Manager und Instanz und die sechs Stufen. Build ohne Warnungen, 709 Tests gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
520 lines
20 KiB
C#
520 lines
20 KiB
C#
using System.Text.Json;
|
|
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Security;
|
|
using ClawdDotNet.Core.Storage;
|
|
using ClawdDotNet.App.Models;
|
|
|
|
namespace ClawdDotNet.App.Services;
|
|
|
|
/// <summary>
|
|
/// Verwaltet die gesamte Verzeichnisstruktur für Instanzen und Agenten.
|
|
///
|
|
/// Layout:
|
|
/// {InstancesDir}/
|
|
/// ├── Instance-{Name}/
|
|
/// │ ├── InstanceSettings.json
|
|
/// │ ├── TokenUsage.json
|
|
/// │ └── Agents/
|
|
/// │ ├── AgentList.json
|
|
/// │ └── Agent-{Name}/
|
|
/// │ ├── AgentSettings.json
|
|
/// │ ├── Soul.md
|
|
/// │ ├── Identity.md
|
|
/// │ ├── Logs/
|
|
/// │ └── Workspace/
|
|
/// </summary>
|
|
public sealed class InstanceDirectoryManager
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
private readonly string _instancesDir;
|
|
private readonly Lock _tokenUsageLock = new();
|
|
|
|
public InstanceDirectoryManager(string instancesDirectory)
|
|
{
|
|
_instancesDir = Path.GetFullPath(instancesDirectory);
|
|
Directory.CreateDirectory(_instancesDir);
|
|
}
|
|
|
|
public string InstancesDirectory => _instancesDir;
|
|
|
|
// ═══════════════════════════════════════════════════
|
|
// INSTANZ-OPERATIONEN
|
|
// ═══════════════════════════════════════════════════
|
|
|
|
public static string BuildInstanceFolderName(string instanceName)
|
|
=> $"Instance-{SanitizeName(instanceName)}";
|
|
|
|
public string GetInstancePath(string instanceName)
|
|
=> Path.Combine(_instancesDir, BuildInstanceFolderName(instanceName));
|
|
|
|
public List<InstanceInfo> ListInstances()
|
|
{
|
|
var result = new List<InstanceInfo>();
|
|
|
|
if (!Directory.Exists(_instancesDir))
|
|
return result;
|
|
|
|
foreach (var dir in Directory.GetDirectories(_instancesDir, "Instance-*"))
|
|
{
|
|
var folderName = Path.GetFileName(dir);
|
|
var settingsPath = Path.Combine(dir, "InstanceSettings.json");
|
|
|
|
var info = new InstanceInfo
|
|
{
|
|
FolderName = folderName,
|
|
FolderPath = dir,
|
|
InstanceName = folderName.Replace("Instance-", "")
|
|
};
|
|
|
|
if (File.Exists(settingsPath))
|
|
{
|
|
try
|
|
{
|
|
var json = File.ReadAllText(settingsPath);
|
|
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts);
|
|
if (config is not null)
|
|
{
|
|
info.InstanceName = config.InstanceName;
|
|
info.ApiKeyStatus = string.IsNullOrWhiteSpace(config.OpenRouterApiKey)
|
|
? "Fehlt" : "Konfiguriert";
|
|
}
|
|
}
|
|
catch { /* defekte Config → Standardwerte */ }
|
|
}
|
|
|
|
// Agenten zählen
|
|
var agentsDir = Path.Combine(dir, "Agents");
|
|
if (Directory.Exists(agentsDir))
|
|
info.AgentCount = Directory.GetDirectories(agentsDir, "Agent-*").Length;
|
|
|
|
result.Add(info);
|
|
}
|
|
|
|
return result.OrderBy(i => i.InstanceName).ToList();
|
|
}
|
|
|
|
public string CreateInstance(string instanceName)
|
|
{
|
|
var instanceDir = GetInstancePath(instanceName);
|
|
|
|
if (Directory.Exists(instanceDir))
|
|
throw new InvalidOperationException($"Instanz '{instanceName}' existiert bereits.");
|
|
|
|
// Hauptverzeichnis
|
|
Directory.CreateDirectory(instanceDir);
|
|
|
|
// Agents-Unterverzeichnis
|
|
var agentsDir = Path.Combine(instanceDir, "Agents");
|
|
Directory.CreateDirectory(agentsDir);
|
|
|
|
// SharedWorkspace-Verzeichnis
|
|
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
|
|
|
|
// InstanceSettings.json
|
|
var config = new InstanceConfig
|
|
{
|
|
InstanceId = Guid.NewGuid().ToString("N")[..8],
|
|
InstanceName = instanceName,
|
|
// LogDirectory und BackupDirectory bleiben leer: Dann gelten
|
|
// <Instanz>/Logs und <Instanz>/Backups. Hier stand vorher "./Logs" — ein
|
|
// Wert, der jede neue Instanz an das Arbeitsverzeichnis des Prozesses band.
|
|
WorkingDirectory = instanceDir
|
|
};
|
|
SaveJson(Path.Combine(instanceDir, "InstanceSettings.json"), config);
|
|
|
|
// TokenUsage.json (leer)
|
|
var tokenUsage = new TokenUsageFile
|
|
{
|
|
InstanceId = config.InstanceId,
|
|
InstanceName = instanceName
|
|
};
|
|
SaveJson(Path.Combine(instanceDir, "TokenUsage.json"), tokenUsage);
|
|
|
|
// AgentList.json (leer)
|
|
SaveJson(Path.Combine(agentsDir, "AgentList.json"), new AgentListFile());
|
|
|
|
return instanceDir;
|
|
}
|
|
|
|
public InstanceConfig LoadInstanceConfig(string instanceDir)
|
|
{
|
|
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
|
|
|
|
if (!File.Exists(settingsPath))
|
|
throw new FileNotFoundException($"InstanceSettings.json nicht gefunden in: {instanceDir}");
|
|
|
|
var json = File.ReadAllText(settingsPath);
|
|
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts)
|
|
?? throw new InvalidOperationException("InstanceSettings.json ist leer oder ungültig.");
|
|
|
|
// Zugangsdaten der Instanz lesbar machen — OpenRouter-Schlüssel, Watchdog-Token,
|
|
// Telegram-Zugang, Token der Weboberfläche.
|
|
//
|
|
// <para>Das fehlte. SaveInstanceConfig verschlüsselt beim Schreiben, hier wurde
|
|
// aber nie entschlüsselt: Was einmal über die Oberfläche gespeichert war, kam
|
|
// als "enc:v2:…" zurück und wurde als Wert benutzt. Der OpenRouter-Schlüssel
|
|
// ging so verschlüsselt an die API. Aufgefallen ist es nie, weil
|
|
// ConfigLoader.LoadAsync — das es richtig macht — im laufenden Programm gar
|
|
// nicht aufgerufen wird; nur seine Migrate-Methode wird gebraucht.</para>
|
|
//
|
|
// <para>Gefahrlos für Klartext aus älteren Dateien: SecretProtector.Unprotect
|
|
// gibt zurück, was es nicht als geschützt erkennt.</para>
|
|
ConfigSecrets.Unprotect(config);
|
|
|
|
// Die alte Vorbelegung "./Logs" wird zu „leer". Beide Schreibweisen zeigen
|
|
// seit #528 (Stufe 1) auf denselben Ort — <Instanz>/Logs —, der leere Wert sagt
|
|
// das aber, ohne es festzuschreiben: Zieht die Instanz um, zieht das Protokoll
|
|
// mit.
|
|
//
|
|
// Nur im Speicher: Die Datei bleibt unangetastet, damit ein Ladevorgang nichts
|
|
// zurückschreibt. Beim nächsten Speichern verschwindet der Wert von selbst.
|
|
if (config.LogDirectory is "./Logs" or @".\Logs" or "Logs")
|
|
config.LogDirectory = "";
|
|
|
|
// Agenten aus Verzeichnisstruktur laden
|
|
config.Agents.Clear();
|
|
var agentsDir = Path.Combine(instanceDir, "Agents");
|
|
|
|
// Sicherstellen, dass SharedWorkspace existiert (Migration)
|
|
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
|
|
|
|
// AgentList.json für Descriptions laden
|
|
var agentListPath = Path.Combine(agentsDir, "AgentList.json");
|
|
var agentList = File.Exists(agentListPath)
|
|
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
|
|
: new AgentListFile();
|
|
|
|
if (Directory.Exists(agentsDir))
|
|
{
|
|
foreach (var agentDir in Directory.GetDirectories(agentsDir, "Agent-*").OrderBy(d => d))
|
|
{
|
|
var agent = LoadAgentConfig(agentDir);
|
|
|
|
var folderName = Path.GetFileName(agentDir);
|
|
var listEntry = agentList.Agents.FirstOrDefault(a => a.FolderName == folderName);
|
|
if (listEntry is not null && !string.IsNullOrWhiteSpace(listEntry.Description))
|
|
agent.Description = listEntry.Description;
|
|
|
|
config.Agents.Add(agent);
|
|
}
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
public void SaveInstanceConfig(string instanceDir, InstanceConfig config)
|
|
{
|
|
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
|
|
|
|
// Zugangsdaten nur für das Schreiben verschlüsseln — die laufende Instanz
|
|
// braucht sie danach wieder im Klartext.
|
|
ConfigSecrets.Protect(config);
|
|
try
|
|
{
|
|
SaveJson(settingsPath, config);
|
|
}
|
|
finally
|
|
{
|
|
ConfigSecrets.Unprotect(config);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════
|
|
// AGENTEN-OPERATIONEN
|
|
// ═══════════════════════════════════════════════════
|
|
|
|
public static string BuildAgentFolderName(string agentName)
|
|
=> $"Agent-{SanitizeName(agentName)}";
|
|
|
|
public string GetAgentPath(string instanceDir, string agentName)
|
|
=> Path.Combine(instanceDir, "Agents", BuildAgentFolderName(agentName));
|
|
|
|
public string CreateAgent(string instanceDir, string agentName, string description = "")
|
|
{
|
|
var agentDir = GetAgentPath(instanceDir, agentName);
|
|
|
|
if (Directory.Exists(agentDir))
|
|
throw new InvalidOperationException($"Agent '{agentName}' existiert bereits in dieser Instanz.");
|
|
|
|
// Verzeichnisse anlegen
|
|
Directory.CreateDirectory(agentDir);
|
|
Directory.CreateDirectory(Path.Combine(agentDir, "Logs"));
|
|
Directory.CreateDirectory(Path.Combine(agentDir, "Workspace"));
|
|
|
|
// AgentSettings.json
|
|
var agentConfig = new AgentConfig
|
|
{
|
|
AgentId = SanitizeName(agentName).ToLowerInvariant(),
|
|
DisplayName = agentName,
|
|
Model = "anthropic/claude-sonnet-4-5"
|
|
};
|
|
SaveAgentSettings(agentDir, agentConfig);
|
|
|
|
// Identity.md
|
|
AtomicFile.WriteAllText(Path.Combine(agentDir, "Identity.md"),
|
|
$"""
|
|
# Identity: {agentName}
|
|
|
|
Du bist **{agentName}**, ein spezialisierter KI-Agent im ClawdDotNet-System.
|
|
|
|
## Rolle
|
|
[Beschreibe hier die Rolle und Verantwortlichkeiten des Agenten]
|
|
|
|
## Expertise
|
|
[Beschreibe hier die Fachgebiete und Fähigkeiten]
|
|
|
|
## Kontext
|
|
[Beschreibe hier den Arbeitskontext und die Teamzugehörigkeit]
|
|
""");
|
|
|
|
// Soul.md
|
|
AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"),
|
|
$"""
|
|
# Soul: {agentName}
|
|
|
|
## Persönlichkeit
|
|
- Gründlich und zuverlässig
|
|
- Klar und präzise in der Kommunikation
|
|
- Proaktiv bei der Problemerkennung
|
|
|
|
## Arbeitsweise
|
|
- Analysiere Aufgaben sorgfältig bevor du handelst
|
|
- Dokumentiere deine Entscheidungen und Ergebnisse
|
|
- Nutze die dir zugewiesenen Tools effizient
|
|
|
|
## Werte
|
|
- Genauigkeit vor Geschwindigkeit
|
|
- Transparenz in der Entscheidungsfindung
|
|
- Sicherheit und Datenschutz haben Priorität
|
|
""");
|
|
|
|
// AgentList.json aktualisieren
|
|
UpdateAgentList(instanceDir, agentName, description, BuildAgentFolderName(agentName));
|
|
|
|
return agentDir;
|
|
}
|
|
|
|
public AgentConfig LoadAgentConfig(string agentDir)
|
|
{
|
|
// AgentSettings.json laden
|
|
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
|
|
AgentConfig config;
|
|
|
|
if (File.Exists(settingsPath))
|
|
{
|
|
var json = File.ReadAllText(settingsPath);
|
|
config = JsonSerializer.Deserialize<AgentConfig>(json, JsonOpts) ?? new AgentConfig();
|
|
ConfigLoader.Migrate(config);
|
|
ConfigSecrets.Unprotect(config);
|
|
}
|
|
else
|
|
{
|
|
config = new AgentConfig
|
|
{
|
|
AgentId = Path.GetFileName(agentDir).Replace("Agent-", "").ToLowerInvariant(),
|
|
DisplayName = Path.GetFileName(agentDir).Replace("Agent-", "")
|
|
};
|
|
}
|
|
|
|
// Identity.md laden
|
|
var identityPath = Path.Combine(agentDir, "Identity.md");
|
|
if (File.Exists(identityPath))
|
|
config.Identity = File.ReadAllText(identityPath);
|
|
|
|
// Soul.md laden
|
|
var soulPath = Path.Combine(agentDir, "Soul.md");
|
|
if (File.Exists(soulPath))
|
|
config.Soul = File.ReadAllText(soulPath);
|
|
|
|
// Agent-Verzeichnis merken (stabil auch bei DisplayName-Änderungen)
|
|
config.AgentDir = Path.GetFullPath(agentDir);
|
|
|
|
// Workspace-Pfad setzen
|
|
config.WorkspacePath = Path.GetFullPath(Path.Combine(agentDir, "Workspace"));
|
|
|
|
// SharedWorkspace-Pfad setzen
|
|
var agentsDir = Path.GetDirectoryName(agentDir); // Dies ist der /Agents Ordner
|
|
if (agentsDir != null)
|
|
{
|
|
config.SharedWorkspacePath = Path.GetFullPath(Path.Combine(agentsDir, "SharedWorkspace"));
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
public void SaveAgentSettings(string agentDir, AgentConfig config)
|
|
{
|
|
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
|
|
|
|
ConfigSecrets.Protect(config);
|
|
try
|
|
{
|
|
SaveJson(settingsPath, config);
|
|
}
|
|
finally
|
|
{
|
|
ConfigSecrets.Unprotect(config);
|
|
}
|
|
}
|
|
|
|
public void SaveAgentIdentity(string agentDir, string identity)
|
|
{
|
|
AtomicFile.WriteAllText(Path.Combine(agentDir, "Identity.md"), identity);
|
|
}
|
|
|
|
public void SaveAgentSoul(string agentDir, string soul)
|
|
{
|
|
AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"), soul);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Nimmt einen Agenten aus dem Betrieb, ohne seine Arbeit wegzuwerfen.
|
|
///
|
|
/// <para><b>Warum umbenennen und nicht löschen.</b> Geladen werden Agenten aus den
|
|
/// Ordnern <c>Agents/Agent-*</c> — was nicht so heißt, wird nicht geladen. Ein
|
|
/// Ordner <c>Entfernt-…</c> ist damit fachlich weg und liegt trotzdem noch da. Im
|
|
/// Arbeitsordner eines Agenten steht seine Arbeit, und ein Klick in einer Liste ist
|
|
/// keine Entscheidung, sie wegzuwerfen. Wer wirklich löschen will, nimmt
|
|
/// <see cref="RemoveAgent"/> — oder den Dateimanager.</para>
|
|
///
|
|
/// <para>Der Zeitstempel im neuen Namen ist kein Schmuck: Ohne ihn scheitert das
|
|
/// zweite Entfernen eines gleichnamigen Agenten daran, dass es das Ziel schon gibt.</para>
|
|
/// </summary>
|
|
/// <returns>Der neue Pfad, oder <c>null</c>, wenn es den Ordner gar nicht gab.</returns>
|
|
public string? ArchiveAgent(string instanceDir, string agentFolderName)
|
|
{
|
|
var agentDir = Path.Combine(instanceDir, "Agents", agentFolderName);
|
|
if (!Directory.Exists(agentDir)) return null;
|
|
|
|
var bare = agentFolderName.StartsWith("Agent-", StringComparison.Ordinal)
|
|
? agentFolderName["Agent-".Length..]
|
|
: agentFolderName;
|
|
|
|
var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
|
|
var archived = Path.Combine(instanceDir, "Agents", $"Entfernt-{bare}-{stamp}");
|
|
|
|
Directory.Move(agentDir, archived);
|
|
|
|
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
|
|
if (File.Exists(agentListPath))
|
|
{
|
|
var list = LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile();
|
|
list.Agents.RemoveAll(a => a.FolderName == agentFolderName);
|
|
SaveJson(agentListPath, list);
|
|
}
|
|
|
|
return archived;
|
|
}
|
|
|
|
public void RemoveAgent(string instanceDir, string agentFolderName)
|
|
{
|
|
var agentDir = Path.Combine(instanceDir, "Agents", agentFolderName);
|
|
if (Directory.Exists(agentDir))
|
|
Directory.Delete(agentDir, recursive: true);
|
|
|
|
// AgentList.json aktualisieren
|
|
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
|
|
if (File.Exists(agentListPath))
|
|
{
|
|
var list = LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile();
|
|
list.Agents.RemoveAll(a => a.FolderName == agentFolderName);
|
|
SaveJson(agentListPath, list);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════
|
|
// TOKEN USAGE
|
|
// ═══════════════════════════════════════════════════
|
|
|
|
public void AppendTokenUsage(string instanceDir, TokenUsageRecord record)
|
|
{
|
|
lock (_tokenUsageLock)
|
|
{
|
|
var path = Path.Combine(instanceDir, "TokenUsage.json");
|
|
TokenUsageFile file;
|
|
|
|
if (File.Exists(path))
|
|
{
|
|
try
|
|
{
|
|
file = LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// Korrupte Datei: Backup erstellen, neu anfangen
|
|
var backupPath = path + $".corrupt_{DateTime.Now:yyyyMMdd_HHmmss}";
|
|
try { File.Copy(path, backupPath, overwrite: true); } catch { /* best effort */ }
|
|
file = new TokenUsageFile();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
file = new TokenUsageFile();
|
|
}
|
|
|
|
file.Records.Add(record);
|
|
SaveJson(path, file);
|
|
}
|
|
}
|
|
|
|
public TokenUsageFile LoadTokenUsage(string instanceDir)
|
|
{
|
|
var path = Path.Combine(instanceDir, "TokenUsage.json");
|
|
return File.Exists(path)
|
|
? LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile()
|
|
: new TokenUsageFile();
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════
|
|
// HILFSMETHODEN
|
|
// ═══════════════════════════════════════════════════
|
|
|
|
private void UpdateAgentList(string instanceDir, string agentName, string description, string folderName)
|
|
{
|
|
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
|
|
var list = File.Exists(agentListPath)
|
|
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
|
|
: new AgentListFile();
|
|
|
|
// Duplikat-Check
|
|
if (list.Agents.All(a => a.FolderName != folderName))
|
|
{
|
|
list.Agents.Add(new AgentListItem
|
|
{
|
|
Name = agentName,
|
|
Description = description,
|
|
FolderName = folderName
|
|
});
|
|
}
|
|
|
|
SaveJson(agentListPath, list);
|
|
}
|
|
|
|
private static void SaveJson<T>(string path, T obj)
|
|
{
|
|
// Atomar: Ein Absturz mitten im Schreiben soll keine halbe Datei hinterlassen.
|
|
// Genau das ist bereits passiert (TokenUsage.json.corrupt_…).
|
|
AtomicFile.WriteAllText(path, JsonSerializer.Serialize(obj, JsonOpts));
|
|
}
|
|
|
|
private static T? LoadJson<T>(string path)
|
|
{
|
|
// Lesen ohne den Schreiber zu blockieren — siehe AtomicFile.ReadAllText.
|
|
var json = AtomicFile.ReadAllText(path);
|
|
return JsonSerializer.Deserialize<T>(json, JsonOpts);
|
|
}
|
|
|
|
private static string SanitizeName(string name)
|
|
{
|
|
var sanitized = name.Trim();
|
|
foreach (var c in Path.GetInvalidFileNameChars())
|
|
sanitized = sanitized.Replace(c, '_');
|
|
return sanitized.Replace(' ', '_');
|
|
}
|
|
}
|