- ClawdDotNet.Web: Kestrel im Prozess der Anwendung, Token-Absicherung (TokenGate mit FixedTimeEquals), Endpunkte unter /api, Ereignisstrom unter /events (Server-Sent Events) und fortlaufendes Log-Streaming. - Weboberflaeche (wwwroot/): Vollstaendiges Single-Page-Frontend mit nativem HTML5, CSS3 und ES-Modulen ohne Buildschritt (strikt textContent, #-Routing, sofortiger Login-Sprung bei fehlendem Token, Light/Dark-Theme, lokale Barlow-Schriften). - ClawdDotNet.App: Fachlogik aus den Avalonia-ViewModels in Dienste herausgeloest (AgentAdministration, JobAdministration, ServiceAdministration, RunCoordinator) und ueber InstanceChange synchronisiert. - Tests: ClawdDotNet.Web.Tests und ClawdDotNet.App.Tests hinzugefuegt, alle 684 Tests erfolgreich.
This commit is contained in:
@@ -63,6 +63,12 @@ public sealed class AppHost : IAsyncDisposable
|
||||
|
||||
public TaskScanner? Scanner { get; private init; }
|
||||
public StagingService? Staging { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Das Taskboard. Null, wenn den Agenten dieser Instanz ein gemeinsamer
|
||||
/// Arbeitsbereich fehlt — dann gibt es keinen Ort, an dem Aufgaben lägen.
|
||||
/// </summary>
|
||||
public TaskboardService? Board { get; private init; }
|
||||
public SqliteUsageRepository? Usage { get; private init; }
|
||||
public TelegramClientManager? TelegramClient { get; private init; }
|
||||
public OpenRouterStatusService? Status { get; private init; }
|
||||
@@ -98,6 +104,68 @@ public sealed class AppHost : IAsyncDisposable
|
||||
/// </summary>
|
||||
public LicenseWatch? LicenseWatch { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Meldet, dass die Instanzkonfiguration geschrieben wurde.
|
||||
///
|
||||
/// <para>Es gibt dieses Ereignis erst, seit es zwei Oberflächen gibt.
|
||||
/// <see cref="Instance"/> ist ein veränderliches Objekt, das beide teilen — die
|
||||
/// Ansichtsmodelle lesen es aber einmal beim Aufbau in ihre Listen. Ohne eine
|
||||
/// Meldung erschiene ein in der Weboberfläche angelegter Agent im laufenden Fenster
|
||||
/// nicht, und umgekehrt genauso.</para>
|
||||
///
|
||||
/// <para><b>Kommt auf dem Faden des Verursachers</b>, nicht auf dem der Oberfläche.
|
||||
/// Wer eine <c>ObservableCollection</c> daran hängt, geht über den Dispatcher.</para>
|
||||
/// </summary>
|
||||
public event Action<InstanceChange>? InstanceChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt die Instanzkonfiguration und sagt Bescheid.
|
||||
///
|
||||
/// <para>Der einzige Weg, auf dem <see cref="Instance"/> gespeichert werden soll.
|
||||
/// <c>Directories.SaveInstanceConfig</c> von Hand zu rufen schreibt zwar dieselbe
|
||||
/// Datei, lässt aber die andere Oberfläche in Unkenntnis.</para>
|
||||
/// </summary>
|
||||
public void SaveInstance(InstanceChange change)
|
||||
{
|
||||
Directories.SaveInstanceConfig(InstancePath, Instance);
|
||||
InstanceChanged?.Invoke(change);
|
||||
}
|
||||
|
||||
// ─── Verwaltung ───
|
||||
//
|
||||
// Die Regeln zum Anlegen, Ändern und Ausführen — bis zum 2026-09-06 in den
|
||||
// Avalonia-Ansichtsmodellen, jetzt hier. Der Grund ist die zweite Oberfläche: Was
|
||||
// dort stand, hätte sie ein zweites Mal schreiben müssen.
|
||||
//
|
||||
// Je Aufbau genau eine Ausprägung, und deshalb hier und nicht beim Aufrufer: Der
|
||||
// RunCoordinator führt den Verlauf und merkt sich die letzten Handläufe. Zwei
|
||||
// Ausprägungen hiessen zwei Verläufe, von denen jede Oberfläche einen anderen zeigt.
|
||||
|
||||
private readonly Lazy<AgentAdministration> _agentAdmin;
|
||||
private readonly Lazy<ServiceAdministration> _serviceAdmin;
|
||||
private readonly Lazy<JobAdministration> _jobAdmin;
|
||||
private readonly Lazy<RunCoordinator> _runs;
|
||||
|
||||
public AppHost()
|
||||
{
|
||||
_agentAdmin = new Lazy<AgentAdministration>(() => new AgentAdministration(this));
|
||||
_serviceAdmin = new Lazy<ServiceAdministration>(() => new ServiceAdministration(this));
|
||||
_jobAdmin = new Lazy<JobAdministration>(() => new JobAdministration(this));
|
||||
_runs = new Lazy<RunCoordinator>(() => new RunCoordinator(this));
|
||||
}
|
||||
|
||||
/// <summary>Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen.</summary>
|
||||
public AgentAdministration AgentAdmin => _agentAdmin.Value;
|
||||
|
||||
/// <summary>Dienste der Instanz anlegen, entfernen, umschalten.</summary>
|
||||
public ServiceAdministration ServiceAdmin => _serviceAdmin.Value;
|
||||
|
||||
/// <summary>Geplante Läufe anlegen, ändern, entfernen, abschalten.</summary>
|
||||
public JobAdministration JobAdmin => _jobAdmin.Value;
|
||||
|
||||
/// <summary>„Jetzt ausführen" samt Verlauf.</summary>
|
||||
public RunCoordinator Runs => _runs.Value;
|
||||
|
||||
/// <summary>Was der Aufrufer beim Start erfragen muss.</summary>
|
||||
public sealed class Callbacks
|
||||
{
|
||||
@@ -362,7 +430,7 @@ public sealed class AppHost : IAsyncDisposable
|
||||
|
||||
engine.LoadPersistedChats();
|
||||
|
||||
var (scanner, staging) = BuildTaskboard(
|
||||
var (scanner, staging, board) = BuildTaskboard(
|
||||
instance, taskRepository, engine, tools, stateStore, audit,
|
||||
stagingRepository, loggerFactory, logger);
|
||||
|
||||
@@ -378,6 +446,7 @@ public sealed class AppHost : IAsyncDisposable
|
||||
Engine = engine,
|
||||
Scanner = scanner,
|
||||
Staging = staging,
|
||||
Board = board,
|
||||
Usage = usage,
|
||||
TelegramClient = telegram,
|
||||
Status = new OpenRouterStatusService(instance.OpenRouterApiKey)
|
||||
@@ -394,7 +463,7 @@ public sealed class AppHost : IAsyncDisposable
|
||||
/// Taskboard und Scanner. Der Scanner ist der einzige periodische Treiber (A1) —
|
||||
/// geplante Agentenläufe wie Tool-Job-Polls sind Tasks.
|
||||
/// </summary>
|
||||
private static (TaskScanner?, StagingService?) BuildTaskboard(
|
||||
private static (TaskScanner?, StagingService?, TaskboardService?) BuildTaskboard(
|
||||
InstanceConfig instance,
|
||||
SqliteTaskRepository taskRepository,
|
||||
AgentEngine engine,
|
||||
@@ -410,7 +479,7 @@ public sealed class AppHost : IAsyncDisposable
|
||||
.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sharedWorkspace))
|
||||
return (null, null);
|
||||
return (null, null, null);
|
||||
|
||||
var board = new TaskboardService(taskRepository, Path.Combine(sharedWorkspace, "tasks"));
|
||||
|
||||
@@ -451,7 +520,7 @@ public sealed class AppHost : IAsyncDisposable
|
||||
}
|
||||
});
|
||||
|
||||
return (scanner, staging);
|
||||
return (scanner, staging, board);
|
||||
}
|
||||
|
||||
private async Task ConnectTelegramAsync(Callbacks callbacks, ILogger logger)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Ergebnis eines verwaltenden Aufrufs: hat es geklappt, und was sagt man dem Benutzer?
|
||||
///
|
||||
/// <para>Bewusst keine Ausnahme für den Normalfall „geht nicht": Eine doppelte
|
||||
/// Agenten-ID ist keine Störung, sondern eine Antwort. Ausnahmen bleiben dem
|
||||
/// vorbehalten, was wirklich schiefgeht — ein nicht schreibbares Instanzverzeichnis
|
||||
/// etwa.</para>
|
||||
///
|
||||
/// <para>Die Meldung ist für Menschen und steht auf Deutsch. Beide Oberflächen zeigen
|
||||
/// dieselbe an; wer sie umformuliert, tut es hier und nicht zweimal.</para>
|
||||
/// </summary>
|
||||
public readonly record struct AdminResult(bool Ok, string Message)
|
||||
{
|
||||
public static AdminResult Success(string message) => new(true, message);
|
||||
public static AdminResult Failure(string message) => new(false, message);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>Die Angaben, die einen neuen Agenten ausmachen.</summary>
|
||||
public sealed record NewAgent(
|
||||
string AgentId,
|
||||
string DisplayName,
|
||||
string Model,
|
||||
string Description = "");
|
||||
|
||||
/// <summary>
|
||||
/// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen.
|
||||
///
|
||||
/// <para>Diese Regeln standen bis zum 2026-09-06 in
|
||||
/// <c>ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs</c> — die Prüfung auf
|
||||
/// doppelte IDs, das Anlegen, das Speichern. Dort war es nicht falsch, solange es genau
|
||||
/// eine Oberfläche gab. Mit der Weboberfläche daneben wäre jede dieser Regeln ein
|
||||
/// zweites Mal geschrieben worden, und ab dem Tag danach wären beide Fassungen
|
||||
/// auseinandergelaufen.</para>
|
||||
///
|
||||
/// <para>Alles, was schreibt, geht über <see cref="AppHost.SaveInstance"/> — nur so
|
||||
/// erfährt die jeweils andere Oberfläche davon.</para>
|
||||
/// </summary>
|
||||
public sealed class AgentAdministration(AppHost host)
|
||||
{
|
||||
/// <summary>
|
||||
/// Die Werkzeuge, die eine Oberfläche zum Ankreuzen anbietet.
|
||||
///
|
||||
/// <para>Fest verdrahtet und länger als das, was in einer Instanz tatsächlich
|
||||
/// registriert ist: Ein Werkzeug soll auch dann in der Liste stehen, wenn es in
|
||||
/// dieser Instanz gerade nicht geladen ist — sonst verschwände mit dem Werkzeug
|
||||
/// stillschweigend auch seine Konfiguration aus der Ansicht. Welche davon wirklich
|
||||
/// da sind, sagt <see cref="ToolDescriptions"/>: dort bleibt die Beschreibung leer.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> KnownTools =
|
||||
[
|
||||
"FileRW", "Telegram", "Mail", "Database", "FTP",
|
||||
"DirectAPI", "WebFetch", "WebMonitor", "AgentComm",
|
||||
"SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
|
||||
];
|
||||
|
||||
public IReadOnlyList<AgentConfig> All => host.Instance.Agents;
|
||||
|
||||
public AgentConfig? Find(string agentId)
|
||||
=> host.Instance.Agents.FirstOrDefault(
|
||||
a => a.AgentId.Equals(agentId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Beschreibungen der tatsächlich geladenen Werkzeuge, nach Namen. Für ein Werkzeug
|
||||
/// aus <see cref="KnownTools"/>, das hier fehlt, bleibt die Beschreibung leer.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> ToolDescriptions()
|
||||
=> host.Tools.GetAll()
|
||||
.GroupBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First().Description, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Legt einen Agenten an — <b>mit Verzeichnis</b>.
|
||||
///
|
||||
/// <para>Die ID wird ohne Rücksicht auf Groß- und Kleinschreibung geprüft. Zwei
|
||||
/// Agenten, die sich nur darin unterscheiden, wären auf der Zielplattform zwar zwei
|
||||
/// verschiedene Ordner, aber für jeden Menschen derselbe Agent.</para>
|
||||
///
|
||||
/// <para><b>Das Verzeichnis ist keine Beigabe, sondern der Ort, an dem ein Agent
|
||||
/// wohnt.</b> <c>LoadInstanceConfig</c> leert beim Start die Agentenliste und baut
|
||||
/// sie aus den Ordnern <c>Agents/Agent-*</c> wieder auf — die Liste in
|
||||
/// <c>InstanceSettings.json</c> wird dabei nicht gelesen. Ein Agent, der nur dort
|
||||
/// steht, ist nach dem nächsten Start weg. Genau das tat die Oberfläche bis zum
|
||||
/// 2026-09-06.</para>
|
||||
/// </summary>
|
||||
public AdminResult Add(NewAgent request)
|
||||
{
|
||||
var agentId = request.AgentId.Trim();
|
||||
var displayName = request.DisplayName.Trim();
|
||||
|
||||
if (agentId.Length == 0)
|
||||
return AdminResult.Failure("Ohne Agenten-ID geht es nicht.");
|
||||
|
||||
if (displayName.Length == 0)
|
||||
return AdminResult.Failure("Ohne Anzeigenamen geht es nicht.");
|
||||
|
||||
if (Find(agentId) is not null)
|
||||
return AdminResult.Failure($"Ein Agent mit der ID '{agentId}' existiert bereits.");
|
||||
|
||||
string agentDir;
|
||||
|
||||
try
|
||||
{
|
||||
// Legt Ordner, AgentSettings.json, Identity.md, Soul.md und den Eintrag in
|
||||
// AgentList.json an. Wirft, wenn es den Ordner schon gibt — zwei Agenten mit
|
||||
// verschiedener ID, aber gleichem Anzeigenamen teilten sich sonst ein
|
||||
// Verzeichnis.
|
||||
agentDir = host.Directories.CreateAgent(
|
||||
host.InstancePath, displayName, request.Description);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return AdminResult.Failure($"Verzeichnis konnte nicht angelegt werden: {ex.Message}");
|
||||
}
|
||||
|
||||
var config = host.Directories.LoadAgentConfig(agentDir);
|
||||
|
||||
// CreateAgent leitet die ID aus dem Namen ab; hier zählt die, nach der gefragt
|
||||
// wurde.
|
||||
config.AgentId = agentId;
|
||||
config.DisplayName = displayName;
|
||||
config.Model = request.Model;
|
||||
config.Description = request.Description;
|
||||
|
||||
host.Directories.SaveAgentSettings(agentDir, config);
|
||||
|
||||
host.Instance.Agents.Add(config);
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
||||
|
||||
return AdminResult.Success($"Agent '{displayName}' angelegt.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nimmt den Agenten aus dem Betrieb.
|
||||
///
|
||||
/// <para><b>Sein Verzeichnis wird umbenannt, nicht gelöscht</b> — es heißt danach
|
||||
/// <c>Entfernt-…</c> und wird beim Start nicht mehr eingelesen. Nur aus der
|
||||
/// Instanzliste zu streichen genügte nicht: Beim nächsten Start stünde der Agent
|
||||
/// wieder da, weil sein Ordner noch da ist. Ihn zu löschen wäre die andere
|
||||
/// Übertreibung — im Arbeitsordner steht seine Arbeit.</para>
|
||||
/// </summary>
|
||||
public AdminResult Remove(string agentId)
|
||||
{
|
||||
if (Find(agentId) is not { } agent)
|
||||
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
||||
|
||||
host.Instance.Agents.Remove(agent);
|
||||
|
||||
var folder = agent.AgentDir.Length > 0
|
||||
? Path.GetFileName(agent.AgentDir)
|
||||
: InstanceDirectoryManager.BuildAgentFolderName(agent.DisplayName);
|
||||
|
||||
string? archived = null;
|
||||
|
||||
try
|
||||
{
|
||||
archived = host.Directories.ArchiveAgent(host.InstancePath, folder);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Der Agent ist aus der Liste; dass sein Ordner liegen bleibt, ist ein
|
||||
// Ärgernis und kein Grund, den Aufruf scheitern zu lassen — sonst stünde er
|
||||
// halb entfernt da. Beim nächsten Start käme er allerdings wieder, und genau
|
||||
// das muss in der Meldung stehen.
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
||||
|
||||
return AdminResult.Failure(
|
||||
$"Agent '{agentId}' aus der Liste genommen, aber sein Verzeichnis ließ sich "
|
||||
+ $"nicht beiseiteräumen ({ex.Message}). Beim nächsten Start ist er wieder da.");
|
||||
}
|
||||
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
||||
|
||||
return AdminResult.Success(archived is null
|
||||
? $"Agent '{agentId}' entfernt."
|
||||
: $"Agent '{agentId}' entfernt. Sein Verzeichnis liegt unter "
|
||||
+ $"'{Path.GetFileName(archived)}'.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lässt ein Werkzeug zu oder nimmt es weg.
|
||||
///
|
||||
/// <para>Zulassen legt eine leere Konfiguration an — das Werkzeug läuft dann mit
|
||||
/// seinen Vorgabewerten. Wegnehmen entfernt den Eintrag samt Einstellungen; das ist
|
||||
/// seit jeher so und der Grund, warum die Oberfläche zwischen „ankreuzen" und
|
||||
/// „Einstellungen speichern" unterscheidet.</para>
|
||||
/// </summary>
|
||||
public AdminResult SetToolEnabled(string agentId, string toolName, bool enabled)
|
||||
{
|
||||
if (Find(agentId) is not { } agent)
|
||||
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
||||
|
||||
if (enabled)
|
||||
{
|
||||
if (!agent.Tools.ContainsKey(toolName))
|
||||
agent.Tools[toolName] = new Dictionary<string, object?>();
|
||||
}
|
||||
else
|
||||
{
|
||||
agent.Tools.Remove(toolName);
|
||||
}
|
||||
|
||||
PersistAgent(agent);
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
||||
|
||||
return AdminResult.Success(enabled
|
||||
? $"Werkzeug '{toolName}' für '{agent.DisplayName}' zugelassen."
|
||||
: $"Werkzeug '{toolName}' für '{agent.DisplayName}' entfernt.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt die Einstellungen eines Werkzeugs.
|
||||
///
|
||||
/// <para>Einstellungen zu schreiben heißt, das Werkzeug zu wollen: Es wird dabei
|
||||
/// zugelassen, falls es das noch nicht war.</para>
|
||||
/// </summary>
|
||||
public AdminResult SetToolSettings(
|
||||
string agentId, string toolName, Dictionary<string, object?> settings)
|
||||
{
|
||||
if (Find(agentId) is not { } agent)
|
||||
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
||||
|
||||
agent.Tools[toolName] = settings;
|
||||
|
||||
PersistAgent(agent);
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
|
||||
|
||||
return AdminResult.Success($"Einstellungen für '{toolName}' gespeichert.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt die Instanzkonfiguration, nachdem der Aufrufer die
|
||||
/// <see cref="AgentConfig"/>-Objekte selbst verändert hat.
|
||||
///
|
||||
/// <para>Für die Avalonia-Oberfläche, deren Ansichtsmodelle über Bindungen direkt auf
|
||||
/// den Konfigurationsobjekten arbeiten und am Ende nur noch speichern wollen. Die
|
||||
/// Weboberfläche hat diesen Fall nicht — sie schickt ganze Felder und sollte die
|
||||
/// Aufrufe oben nehmen.</para>
|
||||
/// </summary>
|
||||
public void Save()
|
||||
{
|
||||
foreach (var agent in host.Instance.Agents)
|
||||
PersistAgent(agent);
|
||||
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen Agenten dorthin, wo er beim nächsten Start gelesen wird.
|
||||
///
|
||||
/// <para><b>Das ist sein eigener Ordner, nicht <c>InstanceSettings.json</c>.</b>
|
||||
/// <c>LoadInstanceConfig</c> leert die Agentenliste und baut sie aus
|
||||
/// <c>Agents/Agent-*/AgentSettings.json</c> wieder auf. Bis zum 2026-09-06 rief
|
||||
/// niemand im laufenden Betrieb <c>SaveAgentSettings</c> — jede Änderung an Modell,
|
||||
/// Prompt, Budget oder Werkzeugen stand nur in <c>InstanceSettings.json</c> und war
|
||||
/// nach dem nächsten Start weg. Es sah aus wie Speichern und war keines.</para>
|
||||
///
|
||||
/// <para><c>Identity</c> und <c>Soul</c> gehen in ihre eigenen Dateien: Von dort
|
||||
/// werden sie gelesen, und was in <c>AgentSettings.json</c> danebenstünde, überschriebe
|
||||
/// der nächste Ladevorgang ohnehin.</para>
|
||||
/// </summary>
|
||||
private void PersistAgent(AgentConfig agent)
|
||||
{
|
||||
// Ein Agent ohne Verzeichnis ist einer aus einer von Hand gepflegten
|
||||
// Instanzdatei. Ihm hier einen Ordner anzulegen waere eine Nebenwirkung des
|
||||
// Speicherns; er bleibt, wie er ist.
|
||||
if (string.IsNullOrWhiteSpace(agent.AgentDir) || !Directory.Exists(agent.AgentDir))
|
||||
return;
|
||||
|
||||
host.Directories.SaveAgentSettings(agent.AgentDir, agent);
|
||||
host.Directories.SaveAgentIdentity(agent.AgentDir, agent.Identity);
|
||||
host.Directories.SaveAgentSoul(agent.AgentDir, agent.Soul);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace ClawdDotNet.App;
|
||||
|
||||
/// <summary>Welcher Teil der Instanzkonfiguration sich geändert hat.</summary>
|
||||
public enum InstanceChangeKind
|
||||
{
|
||||
/// <summary>Ein Agent kam dazu, fiel weg oder wurde bearbeitet.</summary>
|
||||
Agents,
|
||||
|
||||
/// <summary>Ein Dienst kam dazu, fiel weg oder wurde umgeschaltet.</summary>
|
||||
Services,
|
||||
|
||||
/// <summary>Ein geplanter Lauf (Agent-Wakeup oder Tool-Job) hat sich geändert.</summary>
|
||||
Jobs,
|
||||
|
||||
/// <summary>Etwas an der Instanz selbst — Budget, Grenzen, Weboberfläche.</summary>
|
||||
Settings
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine Änderung an der Instanzkonfiguration, so grob, wie eine Oberfläche sie braucht,
|
||||
/// um den richtigen Bereich neu zu laden.
|
||||
///
|
||||
/// <para>Bewusst ohne die geänderten Werte: Beide Oberflächen lesen ohnehin aus
|
||||
/// <c>AppHost.Instance</c>, und ein zweiter Übertragungsweg für dieselben Daten wäre
|
||||
/// eine zweite Wahrheit. <see cref="Id"/> ist ein Hinweis, keine Zusage — wer ihn nicht
|
||||
/// auswerten will, lädt den ganzen Bereich neu.</para>
|
||||
/// </summary>
|
||||
/// <param name="Kind">Welcher Bereich betroffen ist.</param>
|
||||
/// <param name="Id">Agent-, Dienst- oder Job-Kennung, sofern es genau eine gibt.</param>
|
||||
public readonly record struct InstanceChange(InstanceChangeKind Kind, string? Id = null);
|
||||
@@ -360,6 +360,45 @@ public sealed class InstanceDirectoryManager
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Scheduling;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>Die zwei Arten geplanter Läufe.</summary>
|
||||
public enum JobKind
|
||||
{
|
||||
/// <summary>Der Agent wird geweckt und arbeitet seine Aufgaben ab. Höchstens einer je Agent.</summary>
|
||||
AgentWakeup,
|
||||
|
||||
/// <summary>Ein Werkzeug wird periodisch abgefragt. Beliebig viele je Agent.</summary>
|
||||
ToolJob
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein geplanter Lauf, wie ihn eine Oberfläche in einer Liste zeigt.
|
||||
///
|
||||
/// <para>Bewusst mit <see cref="DateTime"/> statt einer fertigen Zeichenkette: Wie ein
|
||||
/// Zeitpunkt aussieht, entscheidet die Anzeige — in einem Fenster anders als in einem
|
||||
/// Browser, der die Zeitzone des Betrachters kennt. Was die Fachschicht beisteuert, ist
|
||||
/// der Zeitpunkt selbst.</para>
|
||||
/// </summary>
|
||||
/// <param name="CronValid">
|
||||
/// Falsch, wenn der Ausdruck nicht lesbar ist. Von „kein Ausdruck hinterlegt" zu
|
||||
/// unterscheiden — dort ist er wahr und <see cref="NextRunUtc"/> leer.
|
||||
/// </param>
|
||||
public sealed record JobSummary(
|
||||
JobKind Kind,
|
||||
string JobId,
|
||||
string AgentId,
|
||||
string AgentName,
|
||||
string ToolName,
|
||||
string Cron,
|
||||
string Message,
|
||||
bool RunOnStart,
|
||||
DateTime? NextRunUtc,
|
||||
bool CronValid,
|
||||
bool Enabled);
|
||||
|
||||
/// <summary>Was einen geplanten Lauf ausmacht — beim Anlegen wie beim Ändern.</summary>
|
||||
public sealed record JobDefinition(
|
||||
string AgentId,
|
||||
JobKind Kind,
|
||||
string ToolName = "",
|
||||
string JobTypeId = "",
|
||||
string Cron = "",
|
||||
string TaskMessage = "",
|
||||
bool RunOnStart = false);
|
||||
|
||||
/// <summary>
|
||||
/// Geplante Läufe anlegen, ändern, entfernen und abschalten.
|
||||
///
|
||||
/// <para>Stand bis zum 2026-09-06 in
|
||||
/// <c>ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs</c>. Die Verschiebung ändert
|
||||
/// nichts an den Regeln — auch nicht daran, dass ein Agent-Wakeup keinen
|
||||
/// Ein-/Aus-Schalter hat: <see cref="SchedulerConfig"/> kennt keinen, und einen hier zu
|
||||
/// erfinden hieße, dass der Scanner ihn erst einmal lesen müsste. Sonst liefe ein
|
||||
/// „abgeschalteter" Wakeup munter weiter — schlimmer als kein Schalter.</para>
|
||||
/// </summary>
|
||||
public sealed class JobAdministration(AppHost host)
|
||||
{
|
||||
/// <summary>Alle geplanten Läufe der Instanz, Agent für Agent.</summary>
|
||||
public IReadOnlyList<JobSummary> List()
|
||||
{
|
||||
var jobs = new List<JobSummary>();
|
||||
|
||||
foreach (var agent in host.Instance.Agents)
|
||||
{
|
||||
if (agent.Scheduler is { } scheduler)
|
||||
{
|
||||
var (next, valid) = NextRun(scheduler.Cron);
|
||||
|
||||
jobs.Add(new JobSummary(
|
||||
JobKind.AgentWakeup,
|
||||
WakeupId(agent.AgentId),
|
||||
agent.AgentId,
|
||||
agent.DisplayName,
|
||||
ToolName: "",
|
||||
scheduler.Cron,
|
||||
scheduler.TaskMessage,
|
||||
scheduler.RunOnStart,
|
||||
next,
|
||||
valid,
|
||||
// Ein Wakeup laeuft, solange der Scanner laeuft — er ist der einzige
|
||||
// periodische Treiber. Ohne ihn steht er, ohne dass jemand ihn
|
||||
// abgeschaltet haette.
|
||||
Enabled: host.Scanner is not null));
|
||||
}
|
||||
|
||||
foreach (var job in agent.ToolJobs)
|
||||
{
|
||||
var (next, valid) = NextRun(job.Cron);
|
||||
|
||||
jobs.Add(new JobSummary(
|
||||
JobKind.ToolJob,
|
||||
job.JobId,
|
||||
agent.AgentId,
|
||||
agent.DisplayName,
|
||||
job.ToolName,
|
||||
job.Cron,
|
||||
job.JobTypeId,
|
||||
job.RunOnStart,
|
||||
next,
|
||||
valid,
|
||||
job.Enabled));
|
||||
}
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der nächste fällige Zeitpunkt in UTC.
|
||||
///
|
||||
/// <para>Ein leerer Ausdruck ist kein Fehler — er heißt „nie". Ein unlesbarer schon;
|
||||
/// deshalb die zwei Rückgaben statt eines <c>null</c>, das beides bedeuten müsste.</para>
|
||||
/// </summary>
|
||||
public static (DateTime? Next, bool Valid) NextRun(string? cron)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cron)) return (null, true);
|
||||
|
||||
try
|
||||
{
|
||||
return (CronExpression.Parse(cron).GetNextOccurrence(DateTime.UtcNow), true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (null, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Kennung eines Agent-Wakeups. Er hat keine eigene — es gibt höchstens einen je
|
||||
/// Agent —, aber eine Liste, in der eine Zeile ausgewählt werden kann, braucht eine.
|
||||
/// </summary>
|
||||
public static string WakeupId(string agentId) => $"agent_{agentId}";
|
||||
|
||||
public AdminResult Add(JobDefinition definition)
|
||||
{
|
||||
if (FindAgent(definition.AgentId) is not { } agent)
|
||||
return AdminResult.Failure($"Kein Agent mit der ID '{definition.AgentId}'.");
|
||||
|
||||
var (_, valid) = NextRun(definition.Cron);
|
||||
if (!valid)
|
||||
return AdminResult.Failure($"Der Zeitplan '{definition.Cron}' ist nicht lesbar.");
|
||||
|
||||
if (definition.Kind == JobKind.ToolJob)
|
||||
{
|
||||
agent.ToolJobs.Add(new ToolJobConfig
|
||||
{
|
||||
ToolName = definition.ToolName,
|
||||
JobTypeId = definition.JobTypeId,
|
||||
Cron = definition.Cron,
|
||||
RunOnStart = definition.RunOnStart,
|
||||
Enabled = true
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
agent.Scheduler = new SchedulerConfig
|
||||
{
|
||||
Cron = definition.Cron,
|
||||
RunOnStart = definition.RunOnStart,
|
||||
TaskMessage = definition.TaskMessage
|
||||
};
|
||||
}
|
||||
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Jobs, definition.AgentId));
|
||||
|
||||
return AdminResult.Success(definition.Kind == JobKind.ToolJob
|
||||
? $"Tool-Job '{definition.JobTypeId}' für '{agent.DisplayName}' angelegt."
|
||||
: $"Wakeup für '{agent.DisplayName}' angelegt.");
|
||||
}
|
||||
|
||||
public AdminResult Update(string jobId, JobDefinition definition)
|
||||
{
|
||||
if (FindAgent(definition.AgentId) is not { } agent)
|
||||
return AdminResult.Failure($"Kein Agent mit der ID '{definition.AgentId}'.");
|
||||
|
||||
var (_, valid) = NextRun(definition.Cron);
|
||||
if (!valid)
|
||||
return AdminResult.Failure($"Der Zeitplan '{definition.Cron}' ist nicht lesbar.");
|
||||
|
||||
if (definition.Kind == JobKind.ToolJob)
|
||||
{
|
||||
if (agent.ToolJobs.FirstOrDefault(j => j.JobId == jobId) is not { } job)
|
||||
return AdminResult.Failure($"Kein Tool-Job mit der Kennung '{jobId}'.");
|
||||
|
||||
job.ToolName = definition.ToolName;
|
||||
job.JobTypeId = definition.JobTypeId;
|
||||
job.Cron = definition.Cron;
|
||||
job.RunOnStart = definition.RunOnStart;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (agent.Scheduler is not { } scheduler)
|
||||
return AdminResult.Failure($"'{agent.DisplayName}' hat keinen Wakeup.");
|
||||
|
||||
scheduler.Cron = definition.Cron;
|
||||
scheduler.TaskMessage = definition.TaskMessage;
|
||||
scheduler.RunOnStart = definition.RunOnStart;
|
||||
}
|
||||
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Jobs, definition.AgentId));
|
||||
|
||||
return AdminResult.Success("Zeitplan gespeichert.");
|
||||
}
|
||||
|
||||
public AdminResult Remove(string agentId, string jobId, JobKind kind)
|
||||
{
|
||||
if (FindAgent(agentId) is not { } agent)
|
||||
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
||||
|
||||
if (kind == JobKind.ToolJob)
|
||||
{
|
||||
if (agent.ToolJobs.RemoveAll(j => j.JobId == jobId) == 0)
|
||||
return AdminResult.Failure($"Kein Tool-Job mit der Kennung '{jobId}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (agent.Scheduler is null)
|
||||
return AdminResult.Failure($"'{agent.DisplayName}' hat keinen Wakeup.");
|
||||
|
||||
agent.Scheduler = null;
|
||||
}
|
||||
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Jobs, agentId));
|
||||
|
||||
return AdminResult.Success("Geplanter Lauf entfernt.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schaltet einen Tool-Job an oder ab, ohne ihn zu entfernen.
|
||||
///
|
||||
/// <para>Nur Tool-Jobs. Warum ein Wakeup das nicht kann, steht in der
|
||||
/// Klassenbeschreibung.</para>
|
||||
/// </summary>
|
||||
public AdminResult SetEnabled(string agentId, string jobId, bool enabled)
|
||||
{
|
||||
if (FindAgent(agentId) is not { } agent)
|
||||
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
||||
|
||||
if (agent.ToolJobs.FirstOrDefault(j => j.JobId == jobId) is not { } job)
|
||||
return AdminResult.Failure(
|
||||
"Nur Tool-Jobs lassen sich abschalten. "
|
||||
+ "Ein Agent-Wakeup wird über die Agenten-Seite entfernt.");
|
||||
|
||||
job.Enabled = enabled;
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Jobs, agentId));
|
||||
|
||||
return AdminResult.Success(enabled
|
||||
? $"Job '{job.JobTypeId}' aktiviert."
|
||||
: $"Job '{job.JobTypeId}' deaktiviert.");
|
||||
}
|
||||
|
||||
private AgentConfig? FindAgent(string agentId)
|
||||
=> host.Instance.Agents.FirstOrDefault(
|
||||
a => a.AgentId.Equals(agentId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ClawdDotNet.App.Models;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// „Jetzt ausführen" — von Hand angestoßene Läufe, gleich von welcher Oberfläche.
|
||||
///
|
||||
/// <para>Stand bis zum 2026-09-06 zweimal in den Ansichtsmodellen: einmal für den
|
||||
/// Agenten (<c>AgentsPageViewModel.RunAgentNowAsync</c>), einmal für den geplanten Lauf
|
||||
/// (<c>TasksPageViewModel.RunJobNowAsync</c>). Beide Fassungen taten fast dasselbe und
|
||||
/// unterschieden sich in dem, was sie in den Verlauf schrieben.</para>
|
||||
///
|
||||
/// <para>Die Unterscheidung, die diese Klasse trägt: <b>ein Tool-Job geht über den
|
||||
/// Scanner, ein Agentenlauf über die Engine.</b> Der Scanner ist der einzige periodische
|
||||
/// Treiber (A1) und kennt die Sperre auf der Aufgabe — ein Tool-Job an ihm vorbei liefe
|
||||
/// womöglich neben dem geplanten Lauf desselben Jobs.</para>
|
||||
/// </summary>
|
||||
public sealed class RunCoordinator
|
||||
{
|
||||
private readonly AppHost _host;
|
||||
|
||||
/// <summary>
|
||||
/// Wann ein Lauf zuletzt von Hand angestoßen wurde, je Job-Kennung. Nur im
|
||||
/// Arbeitsspeicher und bewusst nicht in der Instanzdatei — es ist eine Notiz für die
|
||||
/// Anzeige, keine Konfiguration. Beide Oberflächen teilen sie sich, damit ein im
|
||||
/// Browser angestoßener Lauf auch im Fenster als „zuletzt" auftaucht.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, DateTime> _lastRuns = new();
|
||||
|
||||
public RunCoordinator(AppHost host)
|
||||
{
|
||||
_host = host;
|
||||
History = new JobHistoryService(host.InstancePath);
|
||||
}
|
||||
|
||||
/// <summary>Der Verlauf von Hand angestoßener Läufe. Fasst sich selbst threadsicher an.</summary>
|
||||
public JobHistoryService History { get; }
|
||||
|
||||
public DateTime? LastRun(string jobId)
|
||||
=> _lastRuns.TryGetValue(jobId, out var when) ? when : null;
|
||||
|
||||
/// <summary>
|
||||
/// Weckt einen Agenten von Hand.
|
||||
///
|
||||
/// <para>Ohne eigene Nachricht wird die des geplanten Laufs genommen — der Handlauf
|
||||
/// soll zeigen, was der geplante täte.</para>
|
||||
/// </summary>
|
||||
public async Task<AdminResult> RunAgentAsync(
|
||||
string agentId, string? message, CancellationToken ct)
|
||||
{
|
||||
if (_host.Instance.Agents.FirstOrDefault(
|
||||
a => a.AgentId.Equals(agentId, StringComparison.OrdinalIgnoreCase)) is not { } agent)
|
||||
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
||||
|
||||
if (_host.Engine is null)
|
||||
return AdminResult.Failure("Agent-Engine ist nicht aktiv (kein API-Key).");
|
||||
|
||||
var taskMessage = string.IsNullOrWhiteSpace(message)
|
||||
? agent.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus."
|
||||
: message;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _host.Engine.RunAsync(
|
||||
agent, taskMessage, _host.Instance.InstanceId, ct);
|
||||
|
||||
_lastRuns[JobAdministration.WakeupId(agent.AgentId)] = DateTime.Now;
|
||||
|
||||
History.Add(new JobHistoryEntry
|
||||
{
|
||||
JobName = "Agent Wakeup",
|
||||
Agent = agent.DisplayName,
|
||||
Time = DateTime.Now,
|
||||
JobDescription = taskMessage,
|
||||
Info = $"{result.Status} ({result.StepCount} Steps, {result.TokensUsed:N0} Tokens)",
|
||||
Status = "Manual"
|
||||
});
|
||||
|
||||
return AdminResult.Success(
|
||||
$"'{agent.DisplayName}' abgeschlossen: {result.Status} "
|
||||
+ $"({result.StepCount} Schritte, {result.TokensUsed:N0} Tokens).");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return AdminResult.Failure($"Lauf von '{agent.DisplayName}' abgebrochen.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return AdminResult.Failure($"Lauf fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stößt einen Tool-Job von Hand an — über den Scanner, nicht an ihm vorbei.
|
||||
///
|
||||
/// <para>„Nicht bereit" ist keine Störung: Der Scanner gibt <c>false</c> zurück, wenn
|
||||
/// die Aufgabe gerade von jemand anderem gehalten wird oder gar nicht existiert.
|
||||
/// Genau dafür ist die Sperre da.</para>
|
||||
/// </summary>
|
||||
public async Task<AdminResult> RunToolJobAsync(
|
||||
string agentId, string jobId, CancellationToken ct)
|
||||
{
|
||||
if (_host.Instance.Agents.FirstOrDefault(
|
||||
a => a.AgentId.Equals(agentId, StringComparison.OrdinalIgnoreCase)) is not { } agent)
|
||||
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
|
||||
|
||||
if (agent.ToolJobs.FirstOrDefault(j => j.JobId == jobId) is not { } job)
|
||||
return AdminResult.Failure($"Kein Tool-Job mit der Kennung '{jobId}'.");
|
||||
|
||||
if (_host.Scanner is null)
|
||||
return AdminResult.Failure("Scanner ist nicht aktiv.");
|
||||
|
||||
try
|
||||
{
|
||||
var ran = await _host.Scanner.RunTaskNowAsync($"tj-{agent.AgentId}-{job.JobId}", ct);
|
||||
var info = ran ? "Ausgeführt" : "Nicht bereit";
|
||||
|
||||
_lastRuns[job.JobId] = DateTime.Now;
|
||||
|
||||
History.Add(new JobHistoryEntry
|
||||
{
|
||||
JobName = job.JobTypeId,
|
||||
Agent = agent.DisplayName,
|
||||
Time = DateTime.Now,
|
||||
JobDescription = $"Tool Job: {job.ToolName}",
|
||||
Info = info,
|
||||
Status = "Manual"
|
||||
});
|
||||
|
||||
return AdminResult.Success($"Tool-Job '{job.JobTypeId}' abgeschlossen ({info}).");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return AdminResult.Failure($"Tool-Job '{job.JobTypeId}' abgebrochen.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return AdminResult.Failure($"Fehler beim Ausführen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stößt an, was hinter der Zeile steckt — Wakeup oder Tool-Job.</summary>
|
||||
public Task<AdminResult> RunAsync(JobSummary job, CancellationToken ct)
|
||||
=> job.Kind == JobKind.ToolJob
|
||||
? RunToolJobAsync(job.AgentId, job.JobId, ct)
|
||||
: RunAgentAsync(job.AgentId, job.Message, ct);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>Die Angaben, die einen neuen Dienst ausmachen.</summary>
|
||||
public sealed record NewService(
|
||||
string Name,
|
||||
string Type,
|
||||
int Port,
|
||||
string Description = "");
|
||||
|
||||
/// <summary>
|
||||
/// Dienste der Instanz anlegen, entfernen und umschalten.
|
||||
///
|
||||
/// <para>Stand bis zum 2026-09-06 in
|
||||
/// <c>ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs</c>.</para>
|
||||
///
|
||||
/// <para><b>„Aktivieren" ist nicht „Starten".</b> Umgelegt wird das Kennzeichen
|
||||
/// <c>Enabled</c> in der Instanzkonfiguration; ein laufender Dienstprozess wird davon
|
||||
/// nicht angehalten. Eine Schaltfläche „Stop", nach der etwas weiterläuft, wäre
|
||||
/// schlimmer als gar keine — deshalb heißen die Aufrufe hier so, wie sie wirken.</para>
|
||||
/// </summary>
|
||||
public sealed class ServiceAdministration(AppHost host)
|
||||
{
|
||||
public IReadOnlyList<ServiceConfig> All => host.Instance.Services;
|
||||
|
||||
public ServiceConfig? Find(string serviceId)
|
||||
=> host.Instance.Services.FirstOrDefault(s => s.ServiceId == serviceId);
|
||||
|
||||
/// <summary>
|
||||
/// Ergänzt die eingebauten Diensteinträge, die in dieser Instanz noch fehlen.
|
||||
///
|
||||
/// <para>Beim Aufbau der Aufgaben-Seite gerufen, damit eine frisch angelegte Instanz
|
||||
/// nicht mit einer leeren Liste dasteht. Vorhandene Einträge bleiben unangetastet —
|
||||
/// wer einen abgeschaltet hat, will ihn abgeschaltet haben.</para>
|
||||
/// </summary>
|
||||
public void EnsureBuiltIns()
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
foreach (var builtIn in BuiltInServices.CreateDefaults())
|
||||
{
|
||||
if (host.Instance.Services.Any(s => s.ServiceId == builtIn.ServiceId))
|
||||
continue;
|
||||
|
||||
host.Instance.Services.Insert(0, builtIn);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Services));
|
||||
}
|
||||
|
||||
public AdminResult Add(NewService request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return AdminResult.Failure("Ohne Namen geht es nicht.");
|
||||
|
||||
var service = new ServiceConfig
|
||||
{
|
||||
Name = request.Name,
|
||||
Type = request.Type,
|
||||
Port = request.Port,
|
||||
Enabled = true,
|
||||
Description = request.Description
|
||||
};
|
||||
|
||||
host.Instance.Services.Add(service);
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Services, service.ServiceId));
|
||||
|
||||
return AdminResult.Success($"Dienst '{service.Name}' angelegt.");
|
||||
}
|
||||
|
||||
public AdminResult Remove(string serviceId)
|
||||
{
|
||||
if (Find(serviceId) is not { } service)
|
||||
return AdminResult.Failure($"Kein Dienst mit der Kennung '{serviceId}'.");
|
||||
|
||||
host.Instance.Services.Remove(service);
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Services, serviceId));
|
||||
|
||||
return AdminResult.Success($"Dienst '{service.Name}' entfernt.");
|
||||
}
|
||||
|
||||
public AdminResult SetEnabled(string serviceId, bool enabled)
|
||||
{
|
||||
if (Find(serviceId) is not { } service)
|
||||
return AdminResult.Failure($"Kein Dienst mit der Kennung '{serviceId}'.");
|
||||
|
||||
service.Enabled = enabled;
|
||||
host.SaveInstance(new InstanceChange(InstanceChangeKind.Services, serviceId));
|
||||
|
||||
return AdminResult.Success(enabled
|
||||
? $"Dienst '{service.Name}' aktiviert."
|
||||
: $"Dienst '{service.Name}' deaktiviert.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user