File.WriteAllText kuerzt die Zieldatei zuerst auf null und fuellt sie dann. Bricht der Vorgang dazwischen ab, ist der alte Inhalt weg und der neue unvollstaendig. Das ist im Betrieb bereits eingetreten: In der Instanz TradingTeam lag eine TokenUsage.json.corrupt_..., die die Fehlerbehandlung beiseitegelegt hatte. Der Verbrauch bis dahin war verloren. AtomicFile schreibt in eine Nebendatei, erzwingt das Schreiben auf die Platte und ersetzt dann. Umgestellt sind ChatHistory, ChatContext, alle Instanz- und Agentenkonfigurationen, Identity und Soul, die App-Einstellungen sowie der Stock-Index. Zum Ersetzen wurde das Windows-Verhalten gemessen statt vermutet. Mit einem Leser, der die Zieldatei geoeffnet haelt: Freigabe des Lesers File.Move File.Replace Read scheitert scheitert ReadWrite scheitert scheitert ReadWrite | Delete scheitert funktioniert File.Move verlangt die Zieldatei exklusiv und scheitert deshalb immer, sobald jemand sie geoeffnet hat. Daher File.Replace — und ein Lesehelfer AtomicFile.ReadAllText, der das Loeschen freigibt, damit unsere eigenen Leser keinen Schreiber blockieren. Die Leser in InstanceDirectoryManager und beim Laden der Chatverlaeufe nutzen ihn jetzt. Zusaetzlich ein Schloss je Zieldatei: Zwei gleichzeitige Schreibvorgaenge auf dieselbe Datei sind ohnehin ein Rennen, ohne Serialisierung scheitern sie aber zusaetzlich mit "Zugriff verweigert". Fuer fremde Leser wie Virenscanner bleibt eine Wiederholung mit Wartezeit. 366 Tests gruen (218 Core, 148 Tools). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
455 lines
16 KiB
C#
455 lines
16 KiB
C#
using System.Text.Json;
|
|
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Security;
|
|
using ClawdDotNet.Core.Storage;
|
|
using ClawdDotNet.Models;
|
|
|
|
namespace ClawdDotNet.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 = "./Logs",
|
|
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.");
|
|
|
|
// 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);
|
|
}
|
|
|
|
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(' ', '_');
|
|
}
|
|
}
|