InstanceDirectoryManager nach Core verschoben (Vorarbeit #528 Stufe 3)

Reiner Umzug, keine Verhaltensaenderung. Der Instanzmanager braucht die
Verzeichnisstruktur einer Instanz - anlegen, auflisten, Konfiguration lesen
-, aber er darf ClawdDotNet.App nicht referenzieren: Dort haengen sechzehn
Werkzeugprojekte dran (Telegram, Mail, FTP, Rocket.Chat, ...), und der
Manager soll ausdruecklich keines davon kennen. Ein Projektverweis wuerde
sie nicht ausfuehren, aber laden - und die Zusage "keine Werkzeuge im
Manager" waere nur noch eine Absichtserklaerung statt einer Eigenschaft des
Bauplans.

Das Wissen selbst gehoert ohnehin nach Core: Es beschreibt, wie eine Instanz
auf der Platte liegt, und InstanceConfig und AgentConfig liegen dort schon.

Verschoben nach Core/Instances: InstanceDirectoryManager, InstanceInfo,
TokenUsageRecord/TokenUsageFile, AgentListItem/AgentListFile. In
ClawdDotNet.App/Models bleiben JobHistoryEntry und die
Werkzeug-Ansichtsmodelle, die dort richtig liegen.

Build ohne Warnungen, 717 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-08 22:17:46 +02:00
co-authored by Claude Opus 5
parent 373e1f4caf
commit b78e5d844f
10 changed files with 10 additions and 5 deletions
+1
View File
@@ -29,6 +29,7 @@ using ClawdDotNet.Tools.TelegramClient;
using ClawdDotNet.Tools.WebFetch;
using ClawdDotNet.Tools.WebMonitor;
using Microsoft.Extensions.Logging;
using ClawdDotNet.Core.Instances;
namespace ClawdDotNet.App;
@@ -1,28 +0,0 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.App.Models;
/// <summary>
/// Eintrag in der AgentList.json – Basisinformationen zu einem Agenten.
/// Liegt im Agents/-Ordner einer Instanz.
/// </summary>
public sealed class AgentListItem
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("description")]
public string Description { get; set; } = "";
[JsonPropertyName("folderName")]
public string FolderName { get; set; } = "";
}
/// <summary>
/// Root-Objekt der AgentList.json
/// </summary>
public sealed class AgentListFile
{
[JsonPropertyName("agents")]
public List<AgentListItem> Agents { get; set; } = new();
}
@@ -1,13 +0,0 @@
namespace ClawdDotNet.App.Models;
/// <summary>
/// Zusammenfassung einer Instanz für die Anzeige im InstanceManager.
/// </summary>
public sealed class InstanceInfo
{
public string InstanceName { get; set; } = "";
public string FolderName { get; set; } = "";
public string FolderPath { get; set; } = "";
public int AgentCount { get; set; }
public string ApiKeyStatus { get; set; } = "—";
}
@@ -1,51 +0,0 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.App.Models;
public sealed class TokenUsageRecord
{
[JsonPropertyName("timestamp")]
public DateTime Timestamp { get; set; } = DateTime.Now;
[JsonPropertyName("agentId")]
public string AgentId { get; set; } = "";
[JsonPropertyName("agentName")]
public string AgentName { get; set; } = "";
[JsonPropertyName("model")]
public string Model { get; set; } = "";
[JsonPropertyName("promptTokens")]
public int PromptTokens { get; set; }
[JsonPropertyName("completionTokens")]
public int CompletionTokens { get; set; }
[JsonPropertyName("totalTokens")]
public int TotalTokens { get; set; }
[JsonPropertyName("costUsd")]
public double CostUsd { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = "";
[JsonPropertyName("stepCount")]
public int StepCount { get; set; }
[JsonPropertyName("durationMs")]
public long DurationMs { get; set; }
}
public sealed class TokenUsageFile
{
[JsonPropertyName("instanceId")]
public string InstanceId { get; set; } = "";
[JsonPropertyName("instanceName")]
public string InstanceName { get; set; } = "";
[JsonPropertyName("records")]
public List<TokenUsageRecord> Records { get; set; } = new();
}
@@ -1,4 +1,5 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances;
namespace ClawdDotNet.App.Services;
@@ -1,519 +0,0 @@
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(' ', '_');
}
}