Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Lis.Agent/AgentSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public static IServiceCollection AddLisAgent(this IServiceCollection services) {
kernel.Plugins.AddFromType<FileSystemPlugin>(pluginName: "fs", serviceProvider: sp);
kernel.Plugins.AddFromType<WebPlugin>(pluginName: "web", serviceProvider: sp);
kernel.Plugins.AddFromType<BrowserPlugin>(pluginName: "browser", serviceProvider: sp);
kernel.Plugins.AddFromType<CronPlugin>(pluginName: "cron", serviceProvider: sp);

// Build auth registry from plugin metadata
ToolAuthRegistry authRegistry = sp.GetRequiredService<ToolAuthRegistry>();
Expand Down Expand Up @@ -65,6 +66,7 @@ public static IServiceCollection AddLisAgent(this IServiceCollection services) {
services.AddSingleton<IChatCommand, ModelsCommand>();
services.AddSingleton<IChatCommand, ApproveCommand>();
services.AddSingleton<IChatCommand, DenyCommand>();
services.AddSingleton<IChatCommand, CronCommand>();
services.AddSingleton<CommandRouter>();

// Media
Expand Down
125 changes: 125 additions & 0 deletions Lis.Agent/Commands/CronCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System.Text;
using System.Text.RegularExpressions;

using Cronos;

using Lis.Persistence.Entities;

using Microsoft.EntityFrameworkCore;

namespace Lis.Agent.Commands;

public sealed partial class CronCommand : IChatCommand {
public string[] Triggers => ["/cron"];
public bool OwnerOnly => true;

private const string Usage =
"""
Usage:
/cron add "<cron_expr>" <handler> <name>
/cron list
/cron remove <id>

Examples:
/cron add "*/5 * * * *" daily_summary My Summary Job
/cron add "0 9 * * 1" weekly_report Weekly Report
/cron list
/cron remove 42
""";

public async Task<string> ExecuteAsync(CommandContext ctx, CancellationToken ct) {
if (string.IsNullOrWhiteSpace(ctx.Args))
return Usage;

string args = ctx.Args.Trim();
string subcommand = args.Split(' ', 2)[0].ToLowerInvariant();

return subcommand switch {
"add" => await this.HandleAddAsync(ctx, args[3..].TrimStart(), ct),
"list" => await this.HandleListAsync(ctx, ct),
"remove" => await this.HandleRemoveAsync(ctx, args[6..].TrimStart(), ct),
_ => Usage
};
}

private async Task<string> HandleAddAsync(CommandContext ctx, string args, CancellationToken ct) {
// Parse: "<cron_expr>" <handler> <name...>
Match match = AddPattern().Match(args);
if (!match.Success)
return $"Usage: /cron add \"<cron_expr>\" <handler> <name>\n\nExample: /cron add \"0 9 * * *\" daily_summary Morning Summary";

string cronExpr = match.Groups["cron"].Value;
string handler = match.Groups["handler"].Value;
string name = match.Groups["name"].Value.Trim();

if (string.IsNullOrWhiteSpace(name))
return "Usage: /cron add \"<cron_expr>\" <handler> <name>";

CronExpression cron;
try {
cron = CronExpression.Parse(cronExpr);
} catch (Exception ex) {
return $"❌ Invalid cron expression '{cronExpr}': {ex.Message}";
}

DateTimeOffset now = DateTimeOffset.UtcNow;
DateTimeOffset? nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);

CronJobEntity job = new() {
Name = name,
CronExpression = cronExpr,
Handler = handler,
ChatId = ctx.Chat.Id,
Enabled = true,
IsDeterministic = true,
NextRunAt = nextRun ?? now.AddDays(1),
CreatedAt = now,
UpdatedAt = now
};

ctx.Db.CronJobs.Add(job);
await ctx.Db.SaveChangesAsync(ct);

return $"✅ Cron job #{job.Id} '{job.Name}' created.\nExpression: {job.CronExpression}\nHandler: {job.Handler}\nNext run: {job.NextRunAt:yyyy-MM-dd HH:mm} UTC";
}

private async Task<string> HandleListAsync(CommandContext ctx, CancellationToken ct) {
List<CronJobEntity> jobs = await ctx.Db.CronJobs
.Where(j => j.ChatId == ctx.Chat.Id)
.OrderBy(j => j.Id)
.ToListAsync(ct);

if (jobs.Count == 0)
return "No cron jobs found for this chat.";

StringBuilder sb = new();
sb.AppendLine("📋 Cron jobs:");
foreach (CronJobEntity job in jobs) {
string status = job.Enabled ? "✅" : "⏸️";
string lastRun = job.LastRunAt?.ToString("yyyy-MM-dd HH:mm") ?? "never";
sb.AppendLine($"{status} #{job.Id} | {job.Name} | `{job.CronExpression}` | {job.Handler} | next: {job.NextRunAt:yyyy-MM-dd HH:mm} UTC | last: {lastRun}");
}

return sb.ToString().TrimEnd();
}

private async Task<string> HandleRemoveAsync(CommandContext ctx, string args, CancellationToken ct) {
if (!long.TryParse(args.Trim(), out long id))
return "❌ Invalid job ID. Usage: /cron remove <id>";

CronJobEntity? job = await ctx.Db.CronJobs
.FirstOrDefaultAsync(j => j.Id == id && j.ChatId == ctx.Chat.Id, ct);

if (job is null)
return $"❌ Cron job #{id} not found in this chat.";

string name = job.Name;
ctx.Db.CronJobs.Remove(job);
await ctx.Db.SaveChangesAsync(ct);

return $"✅ Cron job #{id} '{name}' removed.";
}

[GeneratedRegex("""^"(?<cron>[^"]+)"\s+(?<handler>\S+)\s+(?<name>.+)$""")]
private static partial Regex AddPattern();
}
172 changes: 172 additions & 0 deletions Lis.Agent/CronService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
using Cronos;

using Lis.Core.Channel;
using Lis.Core.Cron;
using Lis.Core.Util;
using Lis.Persistence;
using Lis.Persistence.Entities;

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Lis.Agent;

public sealed partial class CronService(
IServiceScopeFactory scopeFactory,
IEnumerable<ICronHandler> handlers,
ILogger<CronService> logger) : IHostedService, IDisposable {

private Timer? _timer;
private readonly Dictionary<string, ICronHandler> _handlers =
handlers.ToDictionary(h => h.HandlerName, StringComparer.OrdinalIgnoreCase);

private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(30);

[Trace("CronService > StartAsync")]
public async Task StartAsync(CancellationToken ct) {
LogStarting(logger, this._handlers.Count);

await this.InitializeNextRunTimesAsync(ct);

this._timer = new Timer(
callback: _ => Task.Run(() => this.TickAsync(CancellationToken.None)),
state: null,
dueTime: TickInterval,
period: TickInterval);
}

public Task StopAsync(CancellationToken ct) {
LogStopping(logger);
this._timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}

public void Dispose() {
this._timer?.Dispose();
}

/// <summary>Resolve a handler by name. Used for testing.</summary>
public ICronHandler? ResolveHandler(string handlerName) {
return this._handlers.GetValueOrDefault(handlerName);
}

[Trace("CronService > InitializeNextRunTimesAsync")]
private async Task InitializeNextRunTimesAsync(CancellationToken ct) {
using IServiceScope scope = scopeFactory.CreateScope();
LisDbContext db = scope.ServiceProvider.GetRequiredService<LisDbContext>();

List<CronJobEntity> jobs = await db.CronJobs
.Where(j => j.Enabled)
.ToListAsync(ct);

DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (CronJobEntity job in jobs) {
try {
CronExpression cron = CronExpression.Parse(job.CronExpression);
DateTimeOffset? next = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
if (next is not null) {
job.NextRunAt = next.Value;
job.UpdatedAt = now;
}
} catch (Exception ex) {
LogCronParseError(logger, job.Id, job.CronExpression, ex);
}
}

await db.SaveChangesAsync(ct);
LogInitialized(logger, jobs.Count);
}

[Trace("CronService > TickAsync")]
private async Task TickAsync(CancellationToken ct) {
try {
using IServiceScope scope = scopeFactory.CreateScope();
LisDbContext db = scope.ServiceProvider.GetRequiredService<LisDbContext>();

DateTimeOffset now = DateTimeOffset.UtcNow;
List<CronJobEntity> dueJobs = await db.CronJobs
.Include(j => j.Chat)
.Where(j => j.Enabled && j.NextRunAt <= now)
.ToListAsync(ct);

if (dueJobs.Count == 0) return;

LogDueJobs(logger, dueJobs.Count);

IChannelClient? channelClient = scope.ServiceProvider.GetService<IChannelClient>();

foreach (CronJobEntity job in dueJobs) {
try {
await this.ExecuteJobAsync(job, channelClient, ct);

CronExpression cron = CronExpression.Parse(job.CronExpression);
DateTimeOffset? next = cron.GetNextOccurrence(DateTimeOffset.UtcNow, TimeZoneInfo.Utc);

job.LastRunAt = DateTimeOffset.UtcNow;
job.NextRunAt = next ?? DateTimeOffset.UtcNow.AddDays(1);
job.UpdatedAt = DateTimeOffset.UtcNow;
} catch (Exception ex) {
LogJobExecutionError(logger, job.Id, job.Name, ex);
}
}

await db.SaveChangesAsync(ct);
} catch (Exception ex) {
LogTickError(logger, ex);
}
}

[Trace("CronService > ExecuteJobAsync")]
private async Task ExecuteJobAsync(CronJobEntity job, IChannelClient? channelClient, CancellationToken ct) {
LogExecutingJob(logger, job.Id, job.Name, job.Handler);

if (!this._handlers.TryGetValue(job.Handler, out ICronHandler? handler)) {
LogNoHandler(logger, job.Handler, job.Id);

if (channelClient is not null) {
await channelClient.SendMessageAsync(
job.Chat.ExternalId,
$"⚠️ Cron job '{job.Name}' failed: no handler registered for '{job.Handler}'.",
ct: ct);
}
return;
}

string? result = await handler.ExecuteAsync(job.ChatId, ct);

if (result is { Length: > 0 } && channelClient is not null) {
await channelClient.SendMessageAsync(job.Chat.ExternalId, result, ct: ct);
}
}

// ── Log Messages ────────────────────────────────────────────────────

[LoggerMessage(Level = LogLevel.Information, Message = "CronService starting — {handlerCount} handlers registered")]
private static partial void LogStarting(ILogger logger, int handlerCount);

[LoggerMessage(Level = LogLevel.Information, Message = "CronService stopping")]
private static partial void LogStopping(ILogger logger);

[LoggerMessage(Level = LogLevel.Information, Message = "Initialized next-run times for {count} jobs")]
private static partial void LogInitialized(ILogger logger, int count);

[LoggerMessage(Level = LogLevel.Information, Message = "Found {count} due cron jobs")]
private static partial void LogDueJobs(ILogger logger, int count);

[LoggerMessage(Level = LogLevel.Information, Message = "Executing cron job {jobId} ({name}) — handler: {handler}")]
private static partial void LogExecutingJob(ILogger logger, long jobId, string name, string handler);

[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to parse cron expression for job {jobId}: {expression}")]
private static partial void LogCronParseError(ILogger logger, long jobId, string expression, Exception ex);

[LoggerMessage(Level = LogLevel.Warning, Message = "No handler registered for '{handler}' (job {jobId})")]
private static partial void LogNoHandler(ILogger logger, string handler, long jobId);

[LoggerMessage(Level = LogLevel.Error, Message = "Failed to execute cron job {jobId} ({name})")]
private static partial void LogJobExecutionError(ILogger logger, long jobId, string name, Exception ex);

[LoggerMessage(Level = LogLevel.Error, Message = "CronService tick failed")]
private static partial void LogTickError(ILogger logger, Exception ex);
}
1 change: 1 addition & 0 deletions Lis.Agent/Lis.Agent.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Cronos" Version="0.8.4" />
<PackageReference Include="Microsoft.Bcl.Memory" Version="10.0.5" />
<PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
<PackageReference Include="Microsoft.ML.Tokenizers.Data.O200kBase" Version="2.0.0" />
Expand Down
3 changes: 3 additions & 0 deletions Lis.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@
builder.Services.AddLisAgent();
}

// Cron scheduler
builder.Services.AddHostedService<CronService>();

WebApplication app = builder.Build();

// Apply migrations on startup + seed default agent
Expand Down
16 changes: 16 additions & 0 deletions Lis.Core/Cron/ICronHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Lis.Core.Cron;

/// <summary>
/// Handles execution of a cron job. Implementations are resolved by handler name.
/// </summary>
public interface ICronHandler {
/// <summary>Unique name used to match cron_job.handler column.</summary>
string HandlerName { get; }

/// <summary>
/// Execute the cron job.
/// Returns a message to send to the chat, or null if no message is needed.
/// For non-deterministic jobs, the returned string is a prompt for the AI.
/// </summary>
Task<string?> ExecuteAsync(long chatId, CancellationToken ct);
}
Loading