feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -1,143 +0,0 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Scheduling;
|
||||
|
||||
public sealed class AgentScheduler : IAsyncDisposable
|
||||
{
|
||||
private readonly AgentEngine _engine;
|
||||
private readonly string _instanceId;
|
||||
private readonly ILogger _logger;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly List<Task> _schedulerTasks = new();
|
||||
private readonly Dictionary<string, AgentRunResult?> _lastResults = new();
|
||||
private readonly Lock _resultsLock = new();
|
||||
|
||||
public event Action<string, AgentRunResult>? OnRunCompleted;
|
||||
|
||||
public AgentScheduler(AgentEngine engine, string instanceId, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_engine = engine;
|
||||
_instanceId = instanceId;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling");
|
||||
}
|
||||
|
||||
public void RegisterAgent(AgentConfig agentConfig)
|
||||
{
|
||||
if (agentConfig.Scheduler is null)
|
||||
return;
|
||||
|
||||
_logger.LogInformation("Registering scheduled agent: {AgentId}, cron='{Cron}', runOnStart={RunOnStart}",
|
||||
agentConfig.AgentId, agentConfig.Scheduler.Cron, agentConfig.Scheduler.RunOnStart);
|
||||
|
||||
var task = RunScheduledAgentAsync(agentConfig, _cts.Token);
|
||||
_schedulerTasks.Add(task);
|
||||
}
|
||||
|
||||
public void RegisterAll(IEnumerable<AgentConfig> agents)
|
||||
{
|
||||
foreach (var agent in agents)
|
||||
RegisterAgent(agent);
|
||||
}
|
||||
|
||||
public async Task<AgentRunResult> RunNowAsync(AgentConfig agentConfig, string userMessage, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Manual run triggered: {AgentId}", agentConfig.AgentId);
|
||||
var result = await _engine.RunAsync(agentConfig, userMessage, _instanceId, ct);
|
||||
StoreResult(agentConfig.AgentId, result);
|
||||
OnRunCompleted?.Invoke(agentConfig.AgentId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public AgentRunResult? GetLastResult(string agentId)
|
||||
{
|
||||
lock (_resultsLock)
|
||||
return _lastResults.GetValueOrDefault(agentId);
|
||||
}
|
||||
|
||||
private async Task RunScheduledAgentAsync(AgentConfig agentConfig, CancellationToken ct)
|
||||
{
|
||||
var scheduler = agentConfig.Scheduler!;
|
||||
|
||||
if (scheduler.RunOnStart)
|
||||
{
|
||||
await ExecuteScheduledRunAsync(agentConfig, ct);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scheduler.Cron))
|
||||
return;
|
||||
|
||||
var cron = CronExpression.Parse(scheduler.Cron);
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var next = cron.GetNextOccurrence(now);
|
||||
|
||||
if (next is null)
|
||||
{
|
||||
_logger.LogWarning("No next occurrence found for agent {AgentId}", agentConfig.AgentId);
|
||||
return;
|
||||
}
|
||||
|
||||
var delay = next.Value - now;
|
||||
_logger.LogDebug("Agent {AgentId} next run at {NextRun}", agentConfig.AgentId, next.Value);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await ExecuteScheduledRunAsync(agentConfig, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteScheduledRunAsync(AgentConfig agentConfig, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _engine.RunAsync(
|
||||
agentConfig,
|
||||
agentConfig.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.",
|
||||
_instanceId,
|
||||
ct);
|
||||
|
||||
StoreResult(agentConfig.AgentId, result);
|
||||
OnRunCompleted?.Invoke(agentConfig.AgentId, result);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Scheduled run completed: {AgentId}, status={Status}, tokens={Tokens}",
|
||||
agentConfig.AgentId, result.Status, result.TokensUsed);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Scheduled run failed for {AgentId}", agentConfig.AgentId);
|
||||
}
|
||||
}
|
||||
|
||||
private void StoreResult(string agentId, AgentRunResult result)
|
||||
{
|
||||
lock (_resultsLock)
|
||||
_lastResults[agentId] = result;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(_schedulerTasks);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,9 @@ public sealed class CronExpression
|
||||
|
||||
foreach (var part in field.Split(','))
|
||||
{
|
||||
if (part.Length == 0)
|
||||
throw Bad(field, "leeres Teilfeld");
|
||||
|
||||
if (part == "*")
|
||||
{
|
||||
for (var i = min; i <= max; i++) result.Add(i);
|
||||
@@ -97,23 +100,44 @@ public sealed class CronExpression
|
||||
else if (part.Contains('/'))
|
||||
{
|
||||
var split = part.Split('/');
|
||||
var start = split[0] == "*" ? min : int.Parse(split[0]);
|
||||
var step = int.Parse(split[1]);
|
||||
if (split.Length != 2)
|
||||
throw Bad(field, "Schrittangabe erwartet die Form 'basis/schritt'");
|
||||
|
||||
var start = split[0] == "*" ? min : ParseNumber(field, split[0], min, max);
|
||||
var step = ParseNumber(field, split[1], 1, max); // Schritt 0 wäre eine Endlosschleife
|
||||
for (var i = start; i <= max; i += step) result.Add(i);
|
||||
}
|
||||
else if (part.Contains('-'))
|
||||
{
|
||||
var split = part.Split('-');
|
||||
var from = int.Parse(split[0]);
|
||||
var to = int.Parse(split[1]);
|
||||
if (split.Length != 2)
|
||||
throw Bad(field, "Bereich erwartet die Form 'von-bis'");
|
||||
|
||||
var from = ParseNumber(field, split[0], min, max);
|
||||
var to = ParseNumber(field, split[1], min, max);
|
||||
if (from > to)
|
||||
throw Bad(field, $"Bereich {from}-{to} ist rückwärts");
|
||||
|
||||
for (var i = from; i <= to; i++) result.Add(i);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(int.Parse(part));
|
||||
result.Add(ParseNumber(field, part, min, max));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int ParseNumber(string field, string value, int min, int max)
|
||||
{
|
||||
if (!int.TryParse(value, out var n))
|
||||
throw Bad(field, $"'{value}' ist keine Zahl");
|
||||
if (n < min || n > max)
|
||||
throw Bad(field, $"{n} liegt außerhalb von {min}-{max}");
|
||||
return n;
|
||||
}
|
||||
|
||||
private static FormatException Bad(string field, string reason)
|
||||
=> new($"Ungültiges Cron-Feld '{field}': {reason}.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace ClawdDotNet.Core.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Deutet Zeitzonen-Kennungen unabhängig davon, auf welchem Betriebssystem sie
|
||||
/// geschrieben wurden.
|
||||
///
|
||||
/// Hintergrund (Linux-Portierung): Zeitzonen werden auf Windows und Linux
|
||||
/// unterschiedlich benannt — <c>"W. Europe Standard Time"</c> gegen
|
||||
/// <c>"Europe/Berlin"</c>. Task-Dateien sind Markdown im geteilten Arbeitsverzeichnis
|
||||
/// und wandern zwischen Rechnern. Eine Kennung, die auf dem einen System entstanden ist,
|
||||
/// muss auf dem anderen lesbar bleiben.
|
||||
///
|
||||
/// Zuvor fing <c>TaskSchedule</c> die unbekannte Kennung ab und rechnete <b>still</b>
|
||||
/// in UTC weiter. Ein Task, der um 08:00 Ortszeit laufen sollte, lief damit im Sommer
|
||||
/// um 06:00 — ohne Meldung, ohne Logeintrag. Deshalb hier: beide Schreibweisen deuten,
|
||||
/// und was sich nicht deuten lässt, meldet <see cref="TryResolve"/> als <c>null</c>
|
||||
/// zurück, statt es zu erraten.
|
||||
///
|
||||
/// <para><b>Voraussetzung auf dem Zielsystem:</b> Die Umsetzung zwischen beiden
|
||||
/// Schreibweisen kommt aus den ICU-Daten, die Zeitzonen selbst aus <c>tzdata</c>. In
|
||||
/// einem schlanken Abbild (Alpine ohne <c>icu-libs</c>, distroless) oder bei
|
||||
/// <c>InvariantGlobalization=true</c> fehlen sie — dann schlägt jede Auflösung außer
|
||||
/// UTC fehl. Beides gehört ins Abbild.</para>
|
||||
/// </summary>
|
||||
public static class TimeZones
|
||||
{
|
||||
/// <summary>
|
||||
/// Die lokale Zeitzone in IANA-Schreibweise (<c>Europe/Berlin</c>).
|
||||
///
|
||||
/// Das ist die Form, die in Task-Dateien geschrieben werden soll: Sie gilt auf
|
||||
/// Linux, macOS und — seit .NET 8 — auch auf Windows.
|
||||
/// </summary>
|
||||
public static string LocalIanaId => ToIana(TimeZoneInfo.Local.Id);
|
||||
|
||||
/// <summary>
|
||||
/// Löst eine Kennung auf, gleich ob IANA- oder Windows-Schreibweise.
|
||||
/// Gibt <c>null</c> zurück, wenn sie auf diesem System nicht auflösbar ist —
|
||||
/// der Aufrufer muss dann entscheiden, und zwar sichtbar.
|
||||
/// </summary>
|
||||
public static TimeZoneInfo? TryResolve(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||
|
||||
var value = id.Trim();
|
||||
|
||||
if (string.Equals(value, "UTC", StringComparison.OrdinalIgnoreCase))
|
||||
return TimeZoneInfo.Utc;
|
||||
|
||||
// Direkt versuchen: .NET nimmt je nach Version und Plattform bereits beide
|
||||
// Formen an. Wenn das reicht, sind wir fertig.
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(value); }
|
||||
catch (TimeZoneNotFoundException) { }
|
||||
catch (InvalidTimeZoneException) { }
|
||||
|
||||
// Sonst die jeweils andere Schreibweise versuchen.
|
||||
if (TimeZoneInfo.TryConvertWindowsIdToIanaId(value, out var iana))
|
||||
{
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(iana); }
|
||||
catch (TimeZoneNotFoundException) { }
|
||||
catch (InvalidTimeZoneException) { }
|
||||
}
|
||||
|
||||
if (TimeZoneInfo.TryConvertIanaIdToWindowsId(value, out var windows))
|
||||
{
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(windows); }
|
||||
catch (TimeZoneNotFoundException) { }
|
||||
catch (InvalidTimeZoneException) { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ob die Kennung auf diesem System auflösbar ist. Leer gilt als gültig — das
|
||||
/// bedeutet „keine Angabe" und wird vom Aufrufer als UTC gedeutet.
|
||||
/// </summary>
|
||||
public static bool IsKnown(string? id)
|
||||
=> string.IsNullOrWhiteSpace(id) || TryResolve(id) is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Bringt eine Kennung auf IANA-Schreibweise. Lässt sie sich nicht umsetzen, kommt
|
||||
/// sie unverändert zurück — eine unbekannte Kennung zu verfälschen wäre schlimmer,
|
||||
/// als sie durchzureichen.
|
||||
/// </summary>
|
||||
public static string ToIana(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id)) return "";
|
||||
|
||||
var value = id.Trim();
|
||||
|
||||
// Enthält einen Schrägstrich → bereits IANA (Windows-Kennungen haben keinen).
|
||||
if (value.Contains('/')) return value;
|
||||
|
||||
return TimeZoneInfo.TryConvertWindowsIdToIanaId(value, out var iana) ? iana : value;
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Scheduling;
|
||||
|
||||
public sealed class ToolJobScheduler : IAsyncDisposable
|
||||
{
|
||||
private readonly AgentEngine _engine;
|
||||
private readonly ToolRegistry _toolRegistry;
|
||||
private readonly IStateStore _stateStore;
|
||||
private readonly string _instanceId;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly List<Task> _schedulerTasks = new();
|
||||
private readonly Dictionary<string, ToolJobResult?> _lastResults = new();
|
||||
private readonly Lock _resultsLock = new();
|
||||
|
||||
public event Action<string, string, ToolJobResult>? OnJobTick;
|
||||
|
||||
public ToolJobScheduler(
|
||||
AgentEngine engine,
|
||||
ToolRegistry toolRegistry,
|
||||
IStateStore stateStore,
|
||||
ILoggerFactory loggerFactory,
|
||||
string instanceId)
|
||||
{
|
||||
_engine = engine;
|
||||
_toolRegistry = toolRegistry;
|
||||
_stateStore = stateStore;
|
||||
_instanceId = instanceId;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling.ToolJob");
|
||||
}
|
||||
|
||||
public void RegisterAll(IEnumerable<AgentConfig> agents)
|
||||
{
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
foreach (var jobConfig in agent.ToolJobs)
|
||||
{
|
||||
if (!jobConfig.Enabled)
|
||||
continue;
|
||||
|
||||
var tool = _toolRegistry.Get(jobConfig.ToolName);
|
||||
if (tool is not IToolJobProvider provider)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tool '{ToolName}' for job '{JobId}' on agent '{AgentId}' is not a IToolJobProvider or not found",
|
||||
jobConfig.ToolName, jobConfig.JobId, agent.AgentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Registering tool job: Agent={AgentId}, Tool={Tool}, JobType={JobType}, Cron={Cron}",
|
||||
agent.AgentId, jobConfig.ToolName, jobConfig.JobTypeId, jobConfig.Cron);
|
||||
|
||||
var task = RunToolJobAsync(agent, jobConfig, provider, _cts.Token);
|
||||
_schedulerTasks.Add(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ToolJobResult? GetLastResult(string jobId)
|
||||
{
|
||||
lock (_resultsLock)
|
||||
return _lastResults.GetValueOrDefault(jobId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt einen Tool-Job sofort manuell aus (außerhalb des Cron-Zeitplans).
|
||||
/// </summary>
|
||||
public async Task<ToolJobResult> TriggerJobAsync(AgentConfig agentConfig, ToolJobConfig jobConfig, CancellationToken ct)
|
||||
{
|
||||
var tool = _toolRegistry.Get(jobConfig.ToolName);
|
||||
if (tool is not IToolJobProvider provider)
|
||||
return ToolJobResult.NoAction($"Tool '{jobConfig.ToolName}' ist kein IToolJobProvider oder nicht registriert.");
|
||||
|
||||
_logger.LogInformation(
|
||||
"Manual trigger: Agent={AgentId}, Job={JobId}, Type={JobType}",
|
||||
agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId);
|
||||
|
||||
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
|
||||
|
||||
lock (_resultsLock)
|
||||
return _lastResults.GetValueOrDefault(jobConfig.JobId)
|
||||
?? ToolJobResult.NoAction("Job wurde ausgeführt, aber kein Ergebnis vorhanden.");
|
||||
}
|
||||
|
||||
private async Task RunToolJobAsync(
|
||||
AgentConfig agentConfig,
|
||||
ToolJobConfig jobConfig,
|
||||
IToolJobProvider provider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (jobConfig.RunOnStart)
|
||||
{
|
||||
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jobConfig.Cron))
|
||||
return;
|
||||
|
||||
var cron = CronExpression.Parse(jobConfig.Cron);
|
||||
|
||||
while (!ct.IsCancellationRequested && jobConfig.Enabled)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var next = cron.GetNextOccurrence(now);
|
||||
|
||||
if (next is null)
|
||||
{
|
||||
_logger.LogWarning("No next occurrence for tool job {JobId}", jobConfig.JobId);
|
||||
return;
|
||||
}
|
||||
|
||||
var delay = next.Value - now;
|
||||
_logger.LogInformation("Tool job {JobId} ({JobType}) next tick at {NextRun}",
|
||||
jobConfig.JobId, jobConfig.JobTypeId, next.Value);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteTickAsync(
|
||||
AgentConfig agentConfig,
|
||||
ToolJobConfig jobConfig,
|
||||
IToolJobProvider provider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var jobLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{jobConfig.ToolName}.Job");
|
||||
|
||||
if (!agentConfig.Tools.ContainsKey(jobConfig.ToolName))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tool '{ToolName}' is no longer assigned to agent '{AgentId}' — disabling job '{JobId}'",
|
||||
jobConfig.ToolName, agentConfig.AgentId, jobConfig.JobId);
|
||||
|
||||
jobConfig.Enabled = false;
|
||||
|
||||
var disabledResult = new ToolJobResult(false, null,
|
||||
$"Job deaktiviert: Agent '{agentConfig.DisplayName}' hat keinen Zugriff auf Tool '{jobConfig.ToolName}'");
|
||||
lock (_resultsLock)
|
||||
_lastResults[jobConfig.JobId] = disabledResult;
|
||||
OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, disabledResult);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var toolConfig = agentConfig.Tools.TryGetValue(jobConfig.ToolName, out var cfg)
|
||||
? (IReadOnlyDictionary<string, object?>)cfg.AsReadOnly()
|
||||
: new Dictionary<string, object?>().AsReadOnly();
|
||||
|
||||
var result = await provider.ExecuteJobAsync(
|
||||
jobConfig.JobTypeId, toolConfig, _stateStore, jobLogger, ct,
|
||||
agentConfig.AgentId, agentConfig.WorkspacePath);
|
||||
|
||||
lock (_resultsLock)
|
||||
_lastResults[jobConfig.JobId] = result;
|
||||
|
||||
OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, result);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Tool job tick: Agent={AgentId}, Job={JobId}, Type={JobType}, Wake={Wake}, Log={Log}",
|
||||
agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId, result.ShouldWakeAgent, result.LogSummary);
|
||||
|
||||
if (result.ShouldWakeAgent && !string.IsNullOrWhiteSpace(result.WakeMessage))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Tool job waking agent: Agent={AgentId}, Job={JobId}, ChatContext={UseChatContext}",
|
||||
agentConfig.AgentId, jobConfig.JobId, result.UseChatContext);
|
||||
|
||||
if (result.UseChatContext)
|
||||
await _engine.ChatAsync(agentConfig, result.WakeMessage, _instanceId, ct, source: ChatSource.Job);
|
||||
else
|
||||
await _engine.RunAsync(agentConfig, result.WakeMessage, _instanceId, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Tool job tick failed: Agent={AgentId}, Job={JobId}",
|
||||
agentConfig.AgentId, jobConfig.JobId);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(_schedulerTasks);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user