Prompt history: every run records its transcript (task, each model step with tokens and cost, tool calls and results, engine follow-ups) - bounded in size, scrubbed of recognisable secrets, system prompts stored once per text. Retention in days per instance (default 14) and per agent (0 = never, -1 = forever); an hourly janitor deletes what is past it, "never" also purges. Usage analysis over run receipts: filter by period, agent, model, event, status, cost and free text; sort by every column; totals over the whole filter; hourly or daily series grouped by agent, model or event; markers for budget denials and model changes; run detail with receipt, transcript, tool calls and approvals. WebUI: Analysis > Token usage > Runs / Timeline, run detail, Prompt History with retention settings. New code, comments and texts in English; texts come from language packs (i18n/en.json, de.json) via modules/i18n.js. CLAUDE.md records English as the main language. Version 0.1.12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
867 lines
38 KiB
C#
867 lines
38 KiB
C#
using ClawdDotNet.App.Services;
|
||
using ClawdDotNet.App.Settings;
|
||
using ClawdDotNet.Core.Accounting;
|
||
using ClawdDotNet.Core.Api;
|
||
using ClawdDotNet.Core.Audit;
|
||
using ClawdDotNet.Core.Config;
|
||
using ClawdDotNet.Core.Engine;
|
||
using ClawdDotNet.Core.Logging;
|
||
using ClawdDotNet.Core.Memory;
|
||
using ClawdDotNet.Core.Security;
|
||
using ClawdDotNet.Core.Staging;
|
||
using ClawdDotNet.Core.State;
|
||
using ClawdDotNet.Core.Storage;
|
||
using ClawdDotNet.Core.Tasks;
|
||
using ClawdDotNet.Core.Deploymentcenter;
|
||
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||
using ClawdDotNet.Core.Tools;
|
||
using ClawdDotNet.Tools.AgentComm;
|
||
using ClawdDotNet.Tools.AgentEditor;
|
||
using ClawdDotNet.Tools.AgentSpawn;
|
||
using ClawdDotNet.Tools.Database;
|
||
using ClawdDotNet.Tools.DirectAPI;
|
||
using ClawdDotNet.Tools.FileRW;
|
||
using ClawdDotNet.Tools.FTP;
|
||
using ClawdDotNet.Tools.Mail;
|
||
using ClawdDotNet.Tools.SocialMediaManager;
|
||
using ClawdDotNet.Tools.Telegram;
|
||
using ClawdDotNet.Tools.TelegramClient;
|
||
using ClawdDotNet.Tools.WebFetch;
|
||
using ClawdDotNet.Tools.WebMonitor;
|
||
using Microsoft.Extensions.Logging;
|
||
using ClawdDotNet.Core.Instances;
|
||
using ClawdDotNet.Licensing;
|
||
|
||
namespace ClawdDotNet.App;
|
||
|
||
/// <summary>
|
||
/// Baut alles auf, was ClawdDotNet zum Laufen braucht — <b>ohne</b> eine einzige Zeile
|
||
/// Oberflächencode.
|
||
///
|
||
/// <para>Vorher lag das in <c>Program.cs</c> der WinForms-Anwendung: 350 Zeilen zwischen
|
||
/// <c>ApplicationConfiguration.Initialize()</c> und <c>Application.Run(form)</c>. Die
|
||
/// Trennung bestand faktisch schon — alles war fertig aufgebaut, bevor das Fenster
|
||
/// überhaupt entstand. Sie war nur nirgends festgehalten.</para>
|
||
///
|
||
/// <para>Damit setzen zwei Aufrufer auf demselben Aufbau auf: die Avalonia-Anwendung
|
||
/// und der geplante systemd-Dienst. Was ein Fenster braucht — Instanzauswahl,
|
||
/// Lizenzabfrage, Telegram-Anmeldung — kommt als Rückruf herein, statt hier
|
||
/// festgeschrieben zu sein.</para>
|
||
/// </summary>
|
||
public sealed class AppHost : IAsyncDisposable
|
||
{
|
||
private readonly List<Func<ValueTask>> _shutdown = [];
|
||
|
||
public required SettingsManager Settings { get; init; }
|
||
public required InstanceDirectoryManager Directories { get; init; }
|
||
public required InstanceConfig Instance { get; init; }
|
||
public required string InstancePath { get; init; }
|
||
public required string LogDirectory { get; init; }
|
||
public required ILoggerFactory LoggerFactory { get; init; }
|
||
public required ToolRegistry Tools { get; init; }
|
||
|
||
/// <summary>
|
||
/// Zustandsspeicher und Audit-Log der Instanz. Nicht oeffentlich — sie gehoeren dem
|
||
/// Aufbau, nicht den Oberflaechen; gebraucht werden sie hier fuer die Sichtung
|
||
/// offener Werkzeugaufrufe beim Start (#554).
|
||
/// </summary>
|
||
internal IStateStore? StateStore { get; private init; }
|
||
|
||
internal IAuditRepository? AuditRepository { get; private init; }
|
||
|
||
/// <summary>
|
||
/// Das geordnete Ende dieser Instanz (#528).
|
||
///
|
||
/// <para>Wer die Instanz beenden will — der Instanzmanager über den Socket, ein
|
||
/// Mensch über das Fenster — ruft <see cref="ShutdownCoordinator.BeginAsync"/> und
|
||
/// wartet, bevor er <see cref="DisposeAsync"/> anstößt. Der Instanzmanager liest
|
||
/// unterdessen <see cref="ShutdownCoordinator.Snapshot"/> über den Puls und weiß
|
||
/// dadurch, dass hier aufgeräumt und nicht gehangen wird.</para>
|
||
/// </summary>
|
||
/// <para>Nicht <c>required</c>, sondern mit einem leeren Koordinator vorbelegt: Ein
|
||
/// Aufbau ohne angemeldete Arbeitsquellen hat nichts abzuwarten und endet sofort. So
|
||
/// gibt es diese Zusage <em>immer</em>, und niemand muss auf <c>null</c> prüfen,
|
||
/// bevor er eine Instanz geordnet beendet.</para>
|
||
public ShutdownCoordinator Shutdown { get; init; } =
|
||
new(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
|
||
|
||
/// <summary>Null, wenn kein OpenRouter-Schlüssel hinterlegt ist — dann laufen keine Agenten.</summary>
|
||
public AgentEngine? Engine { get; private init; }
|
||
|
||
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; }
|
||
|
||
/// <summary>#660: Prompt history - prompt and response of every run.</summary>
|
||
public Core.Transcripts.IRunTranscriptStore? Transcripts { get; private init; }
|
||
|
||
/// <summary>#660: Usage analysis, run details and prompt history for the user interfaces.</summary>
|
||
public RunInsights? Insights { get; private init; }
|
||
|
||
/// <summary>Modellpreise von OpenRouter - fuer Budget und Kostenprognose (#595). Laedt im Hintergrund.</summary>
|
||
public ModelPricingCatalog? Pricing { get; private init; }
|
||
|
||
/// <summary>Das Taskboard fuer die Oberflaechen (#594). Zustandslos, deshalb je Zugriff neu.</summary>
|
||
public TaskAdministration TaskAdmin => new(
|
||
Board, AuditRepository, Scanner, Usage, Pricing, () => Instance.Agents,
|
||
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
|
||
|
||
public TelegramClientManager? TelegramClient { get; private init; }
|
||
public OpenRouterStatusService? Status { get; private init; }
|
||
|
||
/// <summary>
|
||
/// Die taegliche Sicherung. Immer gesetzt — ob sie etwas tut, entscheidet
|
||
/// <c>AutoBackupEnabled</c> bei jedem Durchlauf neu, damit eine Aenderung in den
|
||
/// Einstellungen ohne Neustart greift.
|
||
/// </summary>
|
||
public BackupScheduler? Backups { get; private set; }
|
||
|
||
/// <summary>
|
||
/// Anbindung ans Deploymentcenter (Heartbeat, Fehler-Stream, Bugtracker, Updates).
|
||
/// Null, wenn Adresse oder Token fehlen.
|
||
/// </summary>
|
||
public DeploymentcenterService? Deploymentcenter { get; private set; }
|
||
|
||
/// <summary>
|
||
/// Meldeweg für ungefangene Ausnahmen. Immer gesetzt — ohne Anbindung ist es der
|
||
/// Leerlauf, damit Aufrufer nicht auf null prüfen müssen.
|
||
/// </summary>
|
||
public IErrorReporter Errors => Deploymentcenter?.Errors ?? NullErrorReporter.Instance;
|
||
|
||
/// <summary>
|
||
/// Der Lizenz-Torwächter bleibt über die Laufzeit erhalten: Ein Widerruf soll auch
|
||
/// eine bereits laufende Instanz erreichen, nicht erst den nächsten Start.
|
||
/// </summary>
|
||
public LicenseGate? License { get; private set; }
|
||
|
||
/// <summary>
|
||
/// Ob diese Instanz nachweislich vom Instanzmanager geführt wird (#535).
|
||
///
|
||
/// <para><b>Der geprüfte Befund, nicht der Schalter.</b> <c>--managed</c> ist eine
|
||
/// Behauptung, die jeder aufstellen kann; hier steht das Ergebnis der Prüfung von
|
||
/// <see cref="ManagerHandshake"/>. Wer daran eine Entscheidung hängt — ob die Instanz
|
||
/// sich selbst wechseln darf, ob die Lizenz zentral geprüft wurde —, fragt diesen
|
||
/// Wert und nicht die Befehlszeile.</para>
|
||
/// </summary>
|
||
public bool IsManaged { get; private set; }
|
||
|
||
/// <summary>
|
||
/// Die laufende Nachprüfung. Der Aufrufer hängt sich an
|
||
/// <see cref="LicenseWatch.Revoked"/> und beendet die Anwendung, wenn es feuert.
|
||
/// </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
|
||
{
|
||
/// <summary>
|
||
/// Wählt die Instanz. Gibt <c>null</c> zurück, wenn der Nutzer abbricht.
|
||
/// Ein Dienst liefert hier den fest eingestellten Pfad, ohne zu fragen.
|
||
/// </summary>
|
||
public required Func<InstanceDirectoryManager, Task<string?>> SelectInstance { get; init; }
|
||
|
||
/// <summary>Wie der Lizenz-Torwächter mit dem Benutzer spricht. Null im geführten Modus.</summary>
|
||
public ILicensePrompt? License { get; init; }
|
||
|
||
/// <summary>
|
||
/// Die Behauptung, vom Instanzmanager geführt zu werden — der Schalter
|
||
/// <c>--managed</c>.
|
||
///
|
||
/// <para><b>Eine Behauptung, kein Befund.</b> Ob sie stimmt, entscheidet allein
|
||
/// die Unterschrift des Managers (<see cref="ManagerHandshake"/>); dieser Schalter
|
||
/// sagt nur, dass überhaupt jemand den Anspruch erhebt. Ohne gültige Unterschrift
|
||
/// prüft die Instanz ihre Lizenz selbst.</para>
|
||
/// </summary>
|
||
public bool IsManaged { get; init; }
|
||
|
||
/// <summary>
|
||
/// Telegram-Anmeldecode und 2FA-Passwort. Null lässt die MTProto-Anmeldung aus —
|
||
/// im kopflosen Betrieb der richtige Weg, weil ein Eingabefenster dort einen
|
||
/// Dienst dauerhaft blockieren würde.
|
||
/// </summary>
|
||
public Func<string, Task<string>>? TelegramLogin { get; init; }
|
||
|
||
public Func<Task<string>>? Telegram2FA { get; init; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ergebnis des Aufbaus. <see cref="Host"/> ist null, wenn der Nutzer abgebrochen hat
|
||
/// oder die Lizenz fehlt — der Aufrufer beendet dann, ohne eine Fehlermeldung
|
||
/// nachzureichen: Die hat der Torwächter schon gezeigt.
|
||
/// </summary>
|
||
public readonly record struct StartupResult(AppHost? Host, string? Error);
|
||
|
||
/// <summary>
|
||
/// Wohin diese Instanz protokolliert.
|
||
///
|
||
/// <para>Die Regeln stehen in <see cref="InstanceLayout"/> — sie beschreiben, wie eine
|
||
/// Instanz auf der Platte liegt, und das muss auch der Instanzmanager wissen, der
|
||
/// diese Schicht nicht kennen darf. Hier steht nur die Weiterleitung, damit die
|
||
/// Aufrufer im Fenster nicht zwei Wege lernen müssen.</para>
|
||
/// </summary>
|
||
public static string ResolveLogDirectory(InstanceConfig instance, string instancePath)
|
||
=> InstanceLayout.ResolveLogDirectory(instance, instancePath);
|
||
|
||
/// <inheritdoc cref="InstanceLayout.ResolveBackupDirectory"/>
|
||
public static string ResolveBackupDirectory(InstanceConfig instance, string instancePath)
|
||
=> InstanceLayout.ResolveBackupDirectory(instance, instancePath);
|
||
|
||
public static async Task<StartupResult> StartAsync(Callbacks callbacks, CancellationToken ct = default)
|
||
{
|
||
// ─── 1. Anwendungseinstellungen ───
|
||
var settings = new SettingsManager();
|
||
settings.Load();
|
||
|
||
// ─── 2. Instanz wählen ───
|
||
var directories = new InstanceDirectoryManager(
|
||
Path.GetFullPath(settings.AppSettings.InstancesDirectory));
|
||
|
||
var instancePath = await callbacks.SelectInstance(directories);
|
||
if (string.IsNullOrWhiteSpace(instancePath))
|
||
return new StartupResult(null, null); // Abbruch, keine Meldung nötig
|
||
|
||
InstanceConfig instance;
|
||
try
|
||
{
|
||
instance = directories.LoadInstanceConfig(instancePath);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return new StartupResult(null,
|
||
$"Fehler beim Laden der Instanz:\n{instancePath}\n\n{ex.Message}");
|
||
}
|
||
|
||
// ─── 3. Protokollierung ───
|
||
var logDirectory = ResolveLogDirectory(instance, instancePath);
|
||
|
||
var minLevel = Enum.TryParse<Core.Logging.LogLevel>(
|
||
settings.AppSettings.MinimumLogLevel, true, out var parsed)
|
||
? parsed
|
||
: Core.Logging.LogLevel.Info;
|
||
|
||
var loggerFactory = LoggingExtensions.CreateClawdLoggerFactory(logDirectory, minLevel);
|
||
var logger = loggerFactory.CreateLogger("ClawdDotNet.Startup");
|
||
|
||
logger.LogInformation("ClawdDotNet startet – Instanz: {Instance} ({Id})",
|
||
instance.InstanceName, instance.InstanceId);
|
||
logger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath);
|
||
logger.LogInformation("Protokoll-Verzeichnis: {Path}", logDirectory);
|
||
logger.LogInformation("Einstellungen: {Path}", settings.SettingsPath);
|
||
|
||
// Ein Wechsel ohne Ausweichlogik braucht wenigstens eine Ansage: Wer ein eigenes
|
||
// Log- oder Sicherungsverzeichnis in den Anwendungseinstellungen stehen hatte,
|
||
// soll nicht raten muessen, warum dort nichts mehr ankommt.
|
||
if (settings.AbandonedKeys.Count > 0)
|
||
{
|
||
logger.LogWarning(
|
||
"Diese Einstellungen gelten nicht mehr anwendungsweit und wurden aus "
|
||
+ "{Path} entfernt: {Keys}. Sie stehen jetzt je Instanz in "
|
||
+ "InstanceSettings.json; die Vorgaben dieser Instanz sind {Logs} und {Backups}.",
|
||
settings.SettingsPath,
|
||
string.Join(", ", settings.AbandonedKeys),
|
||
logDirectory,
|
||
ResolveBackupDirectory(instance, instancePath));
|
||
}
|
||
|
||
// Der Schluesselbund, mit dem die Zugangsdaten verschluesselt sind (#190). Was
|
||
// hier fehlt, faellt im Alltag nicht auf und kostet an dem einen Tag alles: Ein
|
||
// Bund ohne Wiederherstellungsfach laeuft tadellos, bis die Maschine wechselt -
|
||
// und dann ist jeder verschluesselte Wert verloren, auch der aus einer Sicherung.
|
||
// Deshalb steht es beim Start da und nicht erst in der Fehlermeldung danach.
|
||
try
|
||
{
|
||
foreach (var problem in SecretKeyStore.Problems)
|
||
logger.LogWarning("Schlüsselbund: {Problem}", problem);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Der Bund wird gleich ohnehin gebraucht; hier nur melden, nicht abbrechen.
|
||
logger.LogError(ex, "Der Schlüsselbund liess sich nicht pruefen.");
|
||
}
|
||
|
||
// ─── 4. Lizenz ───
|
||
//
|
||
// Gefuehrt ist, wer es nachweisen kann (#535). Der Schalter --managed allein
|
||
// genuegte frueher und war damit der Weg an der Lizenz vorbei: Die Instanz
|
||
// uebersprang die Pruefung, weil sie glaubte, der Manager habe sie schon
|
||
// erledigt. Jetzt zaehlt die Unterschrift, und zwar gegen das Verzeichnis, in
|
||
// dem diese Instanz tatsaechlich laeuft.
|
||
var gefuehrt = callbacks.IsManaged
|
||
&& ManagerHandshake.Verify(
|
||
instancePath, Environment.GetEnvironmentVariable(
|
||
ManagerHandshake.EnvironmentVariable));
|
||
|
||
if (callbacks.IsManaged && !gefuehrt)
|
||
{
|
||
// Kein Abbruch: Ohne Nachweis ist die Instanz eben nicht gefuehrt und prueft
|
||
// selbst - der Weg von vor E-M3. Wer eine Lizenz hat, merkt davon nichts.
|
||
logger.LogWarning(
|
||
"Start mit --managed, aber ohne gültige Unterschrift des Instanzmanagers. "
|
||
+ "Die Instanz gilt als eigenständig und prüft ihre Lizenz selbst.");
|
||
}
|
||
|
||
LicenseGate? license = null;
|
||
if (!gefuehrt)
|
||
{
|
||
var prompt = callbacks.License ?? new NonInteractiveLicensePrompt(Console.Error.WriteLine);
|
||
license = new LicenseGate(
|
||
new SettingsLicenseStore(settings),
|
||
loggerFactory.CreateLogger("ClawdDotNet.License"),
|
||
prompt,
|
||
ReleaseInfo.Version);
|
||
|
||
if (!await license.RunStartupCheckAsync(ct))
|
||
{
|
||
logger.LogWarning("Start abgebrochen: keine gültige Lizenz.");
|
||
loggerFactory.Dispose();
|
||
return new StartupResult(null, null);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
logger.LogInformation(
|
||
"Instanz wird vom Instanzmanager geführt (Unterschrift geprüft): "
|
||
+ "Die Lizenzprüfung ist dort schon erfolgt.");
|
||
}
|
||
|
||
// ─── 5. Werkzeuge ───
|
||
var tools = RegisterTools();
|
||
|
||
TelegramClientManager? telegram = null;
|
||
if (instance.TelegramClient is not null && callbacks.TelegramLogin is not null)
|
||
{
|
||
telegram = new TelegramClientManager(instance, instancePath,
|
||
loggerFactory.CreateLogger("ClawdDotNet.Tools.TelegramClient"));
|
||
|
||
tools.Register(new TelegramClientTool(telegram));
|
||
logger.LogInformation("TelegramClient-Tool registriert");
|
||
}
|
||
else if (instance.TelegramClient is not null)
|
||
{
|
||
logger.LogInformation(
|
||
"TelegramClient konfiguriert, aber kein Anmeldeweg vorhanden – übersprungen.");
|
||
}
|
||
|
||
var host = BuildCore(settings, directories, instance, instancePath,
|
||
logDirectory, loggerFactory, tools, telegram, logger);
|
||
|
||
// #554: Nachsehen, was beim letzten Ende offen geblieben ist. Ein geordnetes Ende
|
||
// hinterlaesst nichts; ist hier etwas, ist die Instanz gestorben, waehrend
|
||
// Werkzeuge liefen.
|
||
//
|
||
// Bewusst nur sichten und protokollieren, nicht ausfuehren: Ob die Wirkung eines
|
||
// Aufrufs eingetreten ist, weiss auch dieser Dienst nicht. Ein automatisches
|
||
// "dann eben nochmal" versendete im Zweifel die Mail ein zweites Mal.
|
||
await host.SichteOffeneAufrufeAsync(logger);
|
||
|
||
// Nichts freizugeben: Der Torwächter nutzt den gemeinsamen HttpClient des SDK.
|
||
host.License = license;
|
||
host.IsManaged = gefuehrt;
|
||
|
||
if (license is not null && license.IsEnforcementConfigured)
|
||
{
|
||
host.LicenseWatch = new LicenseWatch(
|
||
license, loggerFactory.CreateLogger("ClawdDotNet.License"));
|
||
|
||
host.LicenseWatch.Start();
|
||
host._shutdown.Add(host.LicenseWatch.DisposeAsync);
|
||
}
|
||
|
||
await host.ConnectTelegramAsync(callbacks, logger);
|
||
await host.StartDeploymentcenterAsync(loggerFactory, ct);
|
||
|
||
// Die Oberflaeche bietet die taegliche Sicherung an und schreibt Uhrzeit und
|
||
// Zielordner in die Instanzkonfiguration — gestartet wurde der Takt dazu bisher
|
||
// nicht, die Einstellung lief also ins Leere.
|
||
host.Backups = new BackupScheduler(
|
||
instancePath, instance,
|
||
loggerFactory.CreateLogger("ClawdDotNet.Backup"));
|
||
|
||
// Eine gefuehrte Instanz sichert sich nicht selbst: Das tut der Manager, und nur er
|
||
// hat den Lizenzschluessel, mit dem die Sicherung verschluesselt wird. Liefen
|
||
// beide, laegen jeden Tag zwei Archive im Ordner - eines davon ohne Zugangsdaten
|
||
// und unverschluesselt. Eigenstaendige Instanzen sichern weiter wie bisher.
|
||
if (gefuehrt)
|
||
{
|
||
logger.LogInformation(
|
||
"Die tägliche Sicherung dieser Instanz übernimmt der Instanzmanager.");
|
||
}
|
||
else
|
||
{
|
||
host.Backups.Start();
|
||
}
|
||
host._shutdown.Add(host.Backups.DisposeAsync);
|
||
|
||
return new StartupResult(host, null);
|
||
}
|
||
|
||
// ─── Aufbau ───
|
||
|
||
private static ToolRegistry RegisterTools()
|
||
{
|
||
var registry = new ToolRegistry();
|
||
|
||
registry.Register(new FileRWTool());
|
||
registry.Register(new TelegramTool());
|
||
registry.Register(new MailTool());
|
||
registry.Register(new DatabaseTool());
|
||
registry.Register(new FTPTool());
|
||
registry.Register(new DirectApiTool());
|
||
registry.Register(new WebFetchTool());
|
||
registry.Register(new WebMonitorTool());
|
||
registry.Register(new AgentCommTool());
|
||
registry.Register(new SocialMediaManagerTool());
|
||
registry.Register(new AgentSpawnTool());
|
||
registry.Register(new AgentEditorTool());
|
||
registry.Register(new Tools.Memory.MemoryTool());
|
||
registry.Register(new Tools.Taskboard.TaskboardTool());
|
||
registry.Register(new Tools.RocketChat.RocketChatTool());
|
||
|
||
return registry;
|
||
}
|
||
|
||
private static AppHost BuildCore(
|
||
SettingsManager settings,
|
||
InstanceDirectoryManager directories,
|
||
InstanceConfig instance,
|
||
string instancePath,
|
||
string logDirectory,
|
||
ILoggerFactory loggerFactory,
|
||
ToolRegistry tools,
|
||
TelegramClientManager? telegram,
|
||
ILogger logger)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(instance.OpenRouterApiKey))
|
||
{
|
||
logger.LogWarning("Kein OpenRouter API-Key konfiguriert – Agenten sind deaktiviert");
|
||
|
||
return new AppHost
|
||
{
|
||
Settings = settings,
|
||
Directories = directories,
|
||
Instance = instance,
|
||
InstancePath = instancePath,
|
||
LogDirectory = logDirectory,
|
||
LoggerFactory = loggerFactory,
|
||
Tools = tools,
|
||
TelegramClient = telegram,
|
||
Shutdown = new ShutdownCoordinator(
|
||
loggerFactory.CreateLogger("ClawdDotNet.Shutdown"))
|
||
};
|
||
}
|
||
|
||
var openRouter = new OpenRouterClient(instance.OpenRouterApiKey,
|
||
loggerFactory.CreateLogger("ClawdDotNet.Core.Api.OpenRouterClient"));
|
||
|
||
// Eine Datenbank je Instanz; StateStore, Gedächtnis und Taskboard teilen sie sich.
|
||
var storage = new SqliteStorage(Path.Combine(instancePath, "state.db"));
|
||
var stateStore = new SqliteStateStore(storage);
|
||
var memory = new SqliteMemoryRepository(storage);
|
||
var taskRepository = new SqliteTaskRepository(storage);
|
||
var audit = new SqliteAuditRepository(storage);
|
||
var stagingRepository = new SqliteStagingRepository(storage);
|
||
var dauerfreigaben = new SqliteDauerfreigabeRepository(storage);
|
||
var stagingGate = new StagingGate(new StagingPolicy(), stagingRepository, dauerfreigaben: dauerfreigaben);
|
||
var usage = new SqliteUsageRepository(storage);
|
||
var transcripts = new Core.Transcripts.SqliteRunTranscriptStore(storage);
|
||
|
||
// Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
|
||
var pricing = new ModelPricingCatalog();
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try { pricing.Load(await openRouter.GetAvailableModelsAsync()); }
|
||
catch { /* Ohne Preise bleibt die Kostengrenze wirkungslos, die Token-Grenze nicht. */ }
|
||
});
|
||
|
||
var engine = new AgentEngine(
|
||
openRouter, tools, new PermissionGate(), stateStore, loggerFactory,
|
||
memory, usage, pricing, taskRepository, audit, stagingGate)
|
||
{
|
||
InstanceBudget = instance.Budget,
|
||
TranscriptStore = transcripts,
|
||
DefaultTranscriptRetention = () => instance.PromptHistory.RetentionDays,
|
||
MaxDelegationDepth = instance.MaxDelegationDepth,
|
||
DelegationGateTimeout = TimeSpan.FromSeconds(instance.DelegationGateTimeoutSeconds)
|
||
};
|
||
|
||
engine.SetAgentConfigProvider(
|
||
() => instance.Agents,
|
||
instance.InstanceId,
|
||
agentId =>
|
||
{
|
||
var agent = instance.Agents.FirstOrDefault(a => a.AgentId == agentId);
|
||
return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir;
|
||
});
|
||
|
||
engine.LoadPersistedChats();
|
||
|
||
var (scanner, staging, board) = BuildTaskboard(
|
||
instance, taskRepository, engine, tools, stateStore, audit,
|
||
stagingRepository, dauerfreigaben, loggerFactory, logger);
|
||
|
||
var host = new AppHost
|
||
{
|
||
Settings = settings,
|
||
Directories = directories,
|
||
Instance = instance,
|
||
InstancePath = instancePath,
|
||
LogDirectory = logDirectory,
|
||
LoggerFactory = loggerFactory,
|
||
Tools = tools,
|
||
StateStore = stateStore,
|
||
AuditRepository = audit,
|
||
Engine = engine,
|
||
Scanner = scanner,
|
||
Staging = staging,
|
||
Board = board,
|
||
Usage = usage,
|
||
Transcripts = transcripts,
|
||
Insights = new RunInsights(audit, transcripts, board, staging,
|
||
agentId => instance.Agents.FirstOrDefault(a => a.AgentId == agentId) is { } agent
|
||
? engine.TranscriptRetentionFor(agent)
|
||
: Core.Transcripts.PromptHistorySettings.Normalize(instance.PromptHistory.RetentionDays)),
|
||
Pricing = pricing,
|
||
TelegramClient = telegram,
|
||
Status = new OpenRouterStatusService(instance.OpenRouterApiKey),
|
||
Shutdown = new ShutdownCoordinator(
|
||
loggerFactory.CreateLogger("ClawdDotNet.Shutdown"))
|
||
};
|
||
|
||
// Was beim Beenden auslaufen darf, bevor abgeschnitten wird (#528). Beide Quellen
|
||
// koennen nur zweierlei: die Tuer schliessen und sagen, wie viel noch laeuft.
|
||
host.Shutdown.Register(new WorkSource(
|
||
"Agentenläufe", () => engine?.OpenRunCount ?? 0, () => engine?.StopAccepting()));
|
||
|
||
host.Shutdown.Register(new WorkSource(
|
||
"Aufgaben", () => scanner?.OpenCount ?? 0, () => scanner?.StopAccepting()));
|
||
|
||
host._shutdown.Add(() => { openRouter.Dispose(); return ValueTask.CompletedTask; });
|
||
|
||
// #660: Retention of the prompt history - per agent, falling back to the instance.
|
||
var janitor = new Core.Transcripts.TranscriptJanitor(
|
||
transcripts,
|
||
agentId => instance.Agents.FirstOrDefault(a => a.AgentId == agentId) is { } agent
|
||
? engine.TranscriptRetentionFor(agent)
|
||
: Core.Transcripts.PromptHistorySettings.Normalize(instance.PromptHistory.RetentionDays),
|
||
loggerFactory.CreateLogger("ClawdDotNet.Core.Transcripts"));
|
||
janitor.Start();
|
||
host._shutdown.Add(janitor.DisposeAsync);
|
||
|
||
// #593: Angelegt wurde der Dienst schon immer, gestartet nie — weder kopflos noch
|
||
// mit Fenster. Die Kopfzeile der Weboberflaeche stand deshalb dauerhaft auf
|
||
// "OpenRouter: —" und /api/status auf "Prüfe…".
|
||
if (host.Status is { } status)
|
||
{
|
||
status.Start();
|
||
host._shutdown.Add(status.DisposeAsync);
|
||
}
|
||
|
||
logger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen");
|
||
|
||
return host;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Taskboard und Scanner. Der Scanner ist der einzige periodische Treiber (A1) —
|
||
/// geplante Agentenläufe wie Tool-Job-Polls sind Tasks.
|
||
/// </summary>
|
||
/// <summary>
|
||
/// Sichtet beim Start die Werkzeugaufrufe, die beim letzten Ende offen blieben (#554),
|
||
/// und schreibt das Ergebnis ins Protokoll.
|
||
///
|
||
/// <para>Scheitert die Sichtung selbst, hält sie den Start nicht auf. Eine Instanz,
|
||
/// die wegen einer misslungenen Nachschau gar nicht erst hochkommt, wäre schlimmer
|
||
/// als eine, die mit einer Wissenslücke startet.</para>
|
||
/// </summary>
|
||
private async Task SichteOffeneAufrufeAsync(ILogger logger)
|
||
{
|
||
// Ohne Protokoll gibt es nichts zu sichten, ohne Zustandsspeicher keine
|
||
// Nachschau. Beides fehlt nur beim Aufbau ohne Datenbank — dann gab es auch
|
||
// keine Werkzeugaufrufe, die offen sein könnten.
|
||
if (AuditRepository is not { } audit || StateStore is not { } stateStore)
|
||
return;
|
||
|
||
var instance = Instance;
|
||
var tools = Tools;
|
||
var loggerFactory = LoggerFactory;
|
||
|
||
try
|
||
{
|
||
// Die Nachschau braucht die Arbeitsverzeichnisse des Agenten — die kennt nur
|
||
// die Instanzkonfiguration. Ist der Agent inzwischen weg, gibt es keinen
|
||
// Kontext und damit keine Nachschau; die Einstufung bleibt trotzdem.
|
||
AgentToolContext? KontextFuer(string agentId, string toolName)
|
||
{
|
||
var agent = instance.Agents.FirstOrDefault(a => a.AgentId == agentId);
|
||
if (agent is null)
|
||
return null;
|
||
|
||
var toolConfig = agent.Tools.TryGetValue(toolName, out var cfg)
|
||
? cfg.AsReadOnly()
|
||
: new Dictionary<string, object?>().AsReadOnly();
|
||
|
||
return new AgentToolContext(
|
||
agent.AgentId,
|
||
instance.InstanceId,
|
||
toolConfig,
|
||
stateStore,
|
||
loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Probe"),
|
||
CancellationToken.None,
|
||
agent.WorkspacePath,
|
||
agent.SharedWorkspacePath);
|
||
}
|
||
|
||
var recovery = new CrashRecovery(audit, tools, loggerFactory, KontextFuer);
|
||
var faelle = await recovery.SichtenAsync(CancellationToken.None);
|
||
|
||
var offen = faelle.Where(f => !f.Erledigt).ToList();
|
||
if (offen.Count > 0)
|
||
{
|
||
logger.LogWarning(
|
||
"{Anzahl} Werkzeugaufruf(e) brauchen eine Entscheidung. Sie wurden NICHT " +
|
||
"wiederholt — ob ihre Wirkung eintrat, ist nicht feststellbar.",
|
||
offen.Count);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex,
|
||
"Die Sichtung offener Werkzeugaufrufe ist gescheitert. Der Start geht weiter.");
|
||
}
|
||
}
|
||
|
||
private static (TaskScanner?, StagingService?, TaskboardService?) BuildTaskboard(
|
||
InstanceConfig instance,
|
||
SqliteTaskRepository taskRepository,
|
||
AgentEngine engine,
|
||
ToolRegistry tools,
|
||
SqliteStateStore stateStore,
|
||
SqliteAuditRepository audit,
|
||
SqliteStagingRepository stagingRepository,
|
||
SqliteDauerfreigabeRepository dauerfreigaben,
|
||
ILoggerFactory loggerFactory,
|
||
ILogger logger)
|
||
{
|
||
var sharedWorkspace = instance.Agents
|
||
.Select(a => a.SharedWorkspacePath)
|
||
.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
|
||
|
||
if (string.IsNullOrWhiteSpace(sharedWorkspace))
|
||
return (null, null, null);
|
||
|
||
var board = new TaskboardService(taskRepository, Path.Combine(sharedWorkspace, "tasks"));
|
||
|
||
var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit,
|
||
dauerfreigaben: dauerfreigaben);
|
||
|
||
var dispatcher = new EngineTaskDispatcher(
|
||
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory);
|
||
|
||
var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory);
|
||
|
||
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
var reset = await taskRepository.ReleaseStaleClaimsAsync(
|
||
DateTime.UtcNow.AddMinutes(-15), DateTime.UtcNow, CancellationToken.None);
|
||
|
||
var coordination = new CoordinationMigration(
|
||
board, Path.Combine(sharedWorkspace, "coordination"), loggerFactory);
|
||
var migratedCoordination = await coordination.RunAsync(CancellationToken.None);
|
||
|
||
var scheduler = new SchedulerTaskMigration(board, taskRepository, loggerFactory);
|
||
var migratedScheduler = await scheduler.RunAsync(instance.Agents, CancellationToken.None);
|
||
|
||
var imported = await board.ImportAllAsync(CancellationToken.None);
|
||
|
||
logger.LogInformation(
|
||
"Taskboard bereit: {Imported} Aufgabe(n), migriert {Coord} coordination + "
|
||
+ "{Sched} scheduler, {Reset} verwaiste Claims zurückgesetzt",
|
||
imported, migratedCoordination, migratedScheduler, reset);
|
||
|
||
scanner.Start();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex, "Taskboard-Reconciliation beim Start fehlgeschlagen");
|
||
}
|
||
});
|
||
|
||
return (scanner, staging, board);
|
||
}
|
||
|
||
private async Task ConnectTelegramAsync(Callbacks callbacks, ILogger logger)
|
||
{
|
||
if (TelegramClient is null || callbacks.TelegramLogin is null) return;
|
||
|
||
TelegramClient.OnLoginCodeRequired += prompt => callbacks.TelegramLogin(prompt);
|
||
|
||
if (callbacks.Telegram2FA is not null)
|
||
TelegramClient.On2FAPasswordRequired += () => callbacks.Telegram2FA();
|
||
|
||
try
|
||
{
|
||
await TelegramClient.ConnectAsync(CancellationToken.None);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogError(ex, "Telegram: Login fehlgeschlagen");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Anbindung ans Deploymentcenter: Instanz-Heartbeat, Fehler-Stream, Bugtracker und
|
||
/// die einmalige Update-Prüfung.
|
||
///
|
||
/// <para>Jede laufende Instanz meldet sich als eigener Monitor — der Server führt
|
||
/// sie über <c>source</c> + <c>instance</c>. Stürzt eine von mehreren ab, fällt
|
||
/// genau deren Monitor, und der Evaluator schlägt nur dafür Alarm.</para>
|
||
/// </summary>
|
||
private async Task StartDeploymentcenterAsync(ILoggerFactory loggerFactory, CancellationToken ct)
|
||
{
|
||
Deploymentcenter = DeploymentcenterService.TryCreate(
|
||
Settings.AppSettings, AppVersion, loggerFactory);
|
||
|
||
if (Deploymentcenter is null)
|
||
return;
|
||
|
||
_shutdown.Add(Deploymentcenter.DisposeAsync);
|
||
|
||
var health = new InstanceHealthProvider(
|
||
Instance.InstanceName,
|
||
agentsEnabled: Engine is not null,
|
||
Instance.Budget,
|
||
Usage,
|
||
() => Instance.Agents.Count,
|
||
() => Engine?.RunningChatCount ?? 0,
|
||
// „Noch nicht gestartet" ist kein Fehler: Der Scanner läuft erst nach der
|
||
// Startabgleichung los, der erste Heartbeat geht sofort raus.
|
||
schedulerRunning: Scanner is null ? null : () => !Scanner.HasStopped);
|
||
|
||
await Deploymentcenter.StartWatchdogAsync(
|
||
Instance, health,
|
||
saveInstanceConfig: () => Directories.SaveInstanceConfig(InstancePath, Instance),
|
||
ct);
|
||
|
||
// Nicht abwarten: Ein langsamer oder stummer Server darf den Start nicht aufhalten.
|
||
_ = Deploymentcenter.CheckForUpdateAsync(Settings.AppSettings, AppVersion, CancellationToken.None);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Die Version, die nach draußen geht: Aktivierungsliste, Heartbeat,
|
||
/// Fehlermeldungen, Versionsvergleich. Kommt aus <c><Version></c> in
|
||
/// <c>Directory.Build.props</c> und wird zur Übersetzungszeit eingebettet
|
||
/// (<c>Deploymentcenter.BuildInfo.targets</c>) — zusammen mit Commit und Build-Datum.
|
||
///
|
||
/// <para>Nicht zu verwechseln mit <c>ClawdDotNet.Core.BuildInfo.Build</c>: das ist
|
||
/// ein von Hand geführter Zähler mit Änderungstext, keine Versionsangabe.</para>
|
||
/// </summary>
|
||
public static string AppVersion => ReleaseInfo.Version;
|
||
|
||
/// <summary>Version, Commit, Build-Datum und Kanal in einer Zeile — für Anzeigen.</summary>
|
||
public static string BuildSummary => ReleaseInfo.Summary;
|
||
|
||
private bool _disposed;
|
||
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
// Beim Update fahren wir vor dem Beenden selbst herunter; danach ruft die
|
||
// Oberflaeche Shutdown, und deren Behandlung raeumt ein zweites Mal auf. Ohne
|
||
// diese Sperre gingen alle Schritte doppelt los — unter anderem ein zweiter
|
||
// Abmeldevorgang beim Watchdog, der den gerade gesetzten Wartungszustand
|
||
// wieder ueberschreibt.
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
|
||
// Erst auslaufen lassen, dann abschneiden (#528). Laeuft das Auslaufen schon,
|
||
// wartet dieser Aufruf darauf statt es ein zweites Mal anzustossen - und wenn
|
||
// niemand darum gebeten hat, ist es hier in einem Wimpernschlag vorbei, weil
|
||
// dann nichts offen ist. Wer DisposeAsync ohne vorheriges BeginAsync ruft,
|
||
// bekommt so trotzdem das geordnete Ende.
|
||
try { await Shutdown.BeginAsync(); }
|
||
catch (Exception) { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ }
|
||
|
||
foreach (var step in _shutdown)
|
||
{
|
||
try { await step(); }
|
||
catch { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ }
|
||
}
|
||
|
||
if (Status is not null) await Status.DisposeAsync();
|
||
if (Scanner is not null) await Scanner.DisposeAsync();
|
||
if (TelegramClient is not null) await TelegramClient.DisposeAsync();
|
||
|
||
LoggerFactory.Dispose();
|
||
}
|
||
}
|