diff --git a/Lis.Agent/AgentSetup.cs b/Lis.Agent/AgentSetup.cs index 97782da..b4e4197 100644 --- a/Lis.Agent/AgentSetup.cs +++ b/Lis.Agent/AgentSetup.cs @@ -35,6 +35,7 @@ public static IServiceCollection AddLisAgent(this IServiceCollection services) { kernel.Plugins.AddFromType(pluginName: "fs", serviceProvider: sp); kernel.Plugins.AddFromType(pluginName: "web", serviceProvider: sp); kernel.Plugins.AddFromType(pluginName: "browser", serviceProvider: sp); + kernel.Plugins.AddFromType(pluginName: "cron", serviceProvider: sp); // Build auth registry from plugin metadata ToolAuthRegistry authRegistry = sp.GetRequiredService(); @@ -65,6 +66,7 @@ public static IServiceCollection AddLisAgent(this IServiceCollection services) { services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // Media diff --git a/Lis.Agent/Commands/CronCommand.cs b/Lis.Agent/Commands/CronCommand.cs new file mode 100644 index 0000000..4051298 --- /dev/null +++ b/Lis.Agent/Commands/CronCommand.cs @@ -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 list + /cron remove + + 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 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 HandleAddAsync(CommandContext ctx, string args, CancellationToken ct) { + // Parse: "" + Match match = AddPattern().Match(args); + if (!match.Success) + return $"Usage: /cron add \"\" \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 \"\" "; + + 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 HandleListAsync(CommandContext ctx, CancellationToken ct) { + List 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 HandleRemoveAsync(CommandContext ctx, string args, CancellationToken ct) { + if (!long.TryParse(args.Trim(), out long id)) + return "❌ Invalid job ID. Usage: /cron remove "; + + 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("""^"(?[^"]+)"\s+(?\S+)\s+(?.+)$""")] + private static partial Regex AddPattern(); +} diff --git a/Lis.Agent/CronService.cs b/Lis.Agent/CronService.cs new file mode 100644 index 0000000..8ad2de9 --- /dev/null +++ b/Lis.Agent/CronService.cs @@ -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 handlers, + ILogger logger) : IHostedService, IDisposable { + + private Timer? _timer; + private readonly Dictionary _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(); + } + + /// Resolve a handler by name. Used for testing. + 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(); + + List 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(); + + DateTimeOffset now = DateTimeOffset.UtcNow; + List 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(); + + 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); +} diff --git a/Lis.Agent/Lis.Agent.csproj b/Lis.Agent/Lis.Agent.csproj index 0533ba8..9be3a48 100644 --- a/Lis.Agent/Lis.Agent.csproj +++ b/Lis.Agent/Lis.Agent.csproj @@ -5,6 +5,7 @@ + diff --git a/Lis.Api/Program.cs b/Lis.Api/Program.cs index a951b07..0250220 100644 --- a/Lis.Api/Program.cs +++ b/Lis.Api/Program.cs @@ -119,6 +119,9 @@ builder.Services.AddLisAgent(); } +// Cron scheduler +builder.Services.AddHostedService(); + WebApplication app = builder.Build(); // Apply migrations on startup + seed default agent diff --git a/Lis.Core/Cron/ICronHandler.cs b/Lis.Core/Cron/ICronHandler.cs new file mode 100644 index 0000000..3fa367f --- /dev/null +++ b/Lis.Core/Cron/ICronHandler.cs @@ -0,0 +1,16 @@ +namespace Lis.Core.Cron; + +/// +/// Handles execution of a cron job. Implementations are resolved by handler name. +/// +public interface ICronHandler { + /// Unique name used to match cron_job.handler column. + string HandlerName { get; } + + /// + /// 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. + /// + Task ExecuteAsync(long chatId, CancellationToken ct); +} diff --git a/Lis.Persistence/Entities/CronJobEntity.cs b/Lis.Persistence/Entities/CronJobEntity.cs new file mode 100644 index 0000000..f3075b9 --- /dev/null +++ b/Lis.Persistence/Entities/CronJobEntity.cs @@ -0,0 +1,76 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Lis.Persistence.Entities; + +[Table("cron_job")] +public sealed class CronJobEntity { + [Key] + [Column("id")] + [JsonPropertyName("id")] + public long Id { get; set; } + + [Required] + [MaxLength(128)] + [Column("name", TypeName = "varchar(128)")] + [JsonPropertyName("name")] + public required string Name { get; set; } + + [Required] + [MaxLength(64)] + [Column("cron_expression", TypeName = "varchar(64)")] + [JsonPropertyName("cron_expression")] + public required string CronExpression { get; set; } + + [Required] + [MaxLength(256)] + [Column("handler", TypeName = "varchar(256)")] + [JsonPropertyName("handler")] + public required string Handler { get; set; } + + [Column("chat_id")] + [JsonPropertyName("chat_id")] + public long ChatId { get; set; } + + [Column("is_deterministic")] + [JsonPropertyName("is_deterministic")] + public bool IsDeterministic { get; set; } = true; + + [Column("enabled")] + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + [Column("next_run_at")] + [JsonPropertyName("next_run_at")] + public DateTimeOffset NextRunAt { get; set; } + + [Column("last_run_at")] + [JsonPropertyName("last_run_at")] + public DateTimeOffset? LastRunAt { get; set; } + + [Column("created_at")] + [JsonPropertyName("created_at")] + public DateTimeOffset CreatedAt { get; set; } + + [Column("updated_at")] + [JsonPropertyName("updated_at")] + public DateTimeOffset UpdatedAt { get; set; } + + public ChatEntity Chat { get; set; } = null!; +} + +public class CronJobEntityConfiguration : IEntityTypeConfiguration { + public void Configure(EntityTypeBuilder builder) { + builder.HasIndex(e => e.ChatId); + builder.HasIndex(e => new { e.Enabled, e.NextRunAt }); + + builder.HasOne(e => e.Chat) + .WithMany() + .HasForeignKey(e => e.ChatId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/Lis.Persistence/LisDbContext.cs b/Lis.Persistence/LisDbContext.cs index 8e4f273..c6e9d34 100644 --- a/Lis.Persistence/LisDbContext.cs +++ b/Lis.Persistence/LisDbContext.cs @@ -16,6 +16,7 @@ public class LisDbContext(DbContextOptions options) :DbContext(opt public DbSet ChatAllowedSenders { get; init; } = null!; public DbSet ExecApprovals { get; init; } = null!; public DbSet ExecAllowlist { get; init; } = null!; + public DbSet CronJobs { get; init; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasPostgresExtension("vector"); diff --git a/Lis.Persistence/Migrations/20260322144800_add_cron_jobs.Designer.cs b/Lis.Persistence/Migrations/20260322144800_add_cron_jobs.Designer.cs new file mode 100644 index 0000000..e6cb92e --- /dev/null +++ b/Lis.Persistence/Migrations/20260322144800_add_cron_jobs.Designer.cs @@ -0,0 +1,1084 @@ +ο»Ώ// +using System; +using Lis.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pgvector; + +#nullable disable + +namespace Lis.Persistence.Migrations +{ + [DbContext(typeof(LisDbContext))] + [Migration("20260322144800_add_cron_jobs")] + partial class add_cron_jobs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Lis.Persistence.Entities.AgentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasJsonPropertyName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompactionThreshold") + .HasColumnType("integer") + .HasColumnName("compaction_threshold") + .HasJsonPropertyName("compaction_threshold"); + + b.Property("ContextBudget") + .HasColumnType("integer") + .HasColumnName("context_budget") + .HasJsonPropertyName("context_budget"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasJsonPropertyName("created_at"); + + b.Property("DisplayName") + .HasMaxLength(128) + .HasColumnType("varchar(128)") + .HasColumnName("display_name") + .HasJsonPropertyName("display_name"); + + b.Property("ExecSecurity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("varchar(16)") + .HasColumnName("exec_security") + .HasJsonPropertyName("exec_security"); + + b.Property("ExecTimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("exec_timeout_seconds") + .HasJsonPropertyName("exec_timeout_seconds"); + + b.Property("GroupContextPrompt") + .HasColumnType("text") + .HasColumnName("group_context_prompt") + .HasJsonPropertyName("group_context_prompt"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default") + .HasJsonPropertyName("is_default"); + + b.Property("KeepRecentTokens") + .HasColumnType("integer") + .HasColumnName("keep_recent_tokens") + .HasJsonPropertyName("keep_recent_tokens"); + + b.Property("MaxTokens") + .HasColumnType("integer") + .HasColumnName("max_tokens") + .HasJsonPropertyName("max_tokens"); + + b.Property("MentionTriggers") + .HasMaxLength(256) + .HasColumnType("varchar(256)") + .HasColumnName("mention_triggers") + .HasJsonPropertyName("mention_triggers"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)") + .HasColumnName("model") + .HasJsonPropertyName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("name") + .HasJsonPropertyName("name"); + + b.Property("ThinkingEffort") + .HasMaxLength(16) + .HasColumnType("varchar(16)") + .HasColumnName("thinking_effort") + .HasJsonPropertyName("thinking_effort"); + + b.Property("ToolKeepThreshold") + .HasColumnType("integer") + .HasColumnName("tool_keep_threshold") + .HasJsonPropertyName("tool_keep_threshold"); + + b.Property("ToolNotifications") + .HasColumnType("boolean") + .HasColumnName("tool_notifications") + .HasJsonPropertyName("tool_notifications"); + + b.Property("ToolProfile") + .HasMaxLength(32) + .HasColumnType("varchar(32)") + .HasColumnName("tool_profile") + .HasJsonPropertyName("tool_profile"); + + b.Property("ToolPruneThreshold") + .HasColumnType("integer") + .HasColumnName("tool_prune_threshold") + .HasJsonPropertyName("tool_prune_threshold"); + + b.Property("ToolSummarizationPolicy") + .HasMaxLength(16) + .HasColumnType("varchar(16)") + .HasColumnName("tool_summarization_policy") + .HasJsonPropertyName("tool_summarization_policy"); + + b.Property("ToolsAllow") + .HasColumnType("text") + .HasColumnName("tools_allow") + .HasJsonPropertyName("tools_allow"); + + b.Property("ToolsDeny") + .HasColumnType("text") + .HasColumnName("tools_deny") + .HasJsonPropertyName("tools_deny"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasJsonPropertyName("updated_at"); + + b.Property("WorkspacePath") + .HasColumnType("text") + .HasColumnName("workspace_path") + .HasJsonPropertyName("workspace_path"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("agent"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.ChatAllowedSenderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasJsonPropertyName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChatId") + .HasColumnType("bigint") + .HasColumnName("chat_id") + .HasJsonPropertyName("chat_id"); + + b.Property("SenderId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("sender_id") + .HasJsonPropertyName("sender_id"); + + b.HasKey("Id"); + + b.HasIndex("ChatId"); + + b.HasIndex("ChatId", "SenderId") + .IsUnique(); + + b.ToTable("chat_allowed_sender"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.ChatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasJsonPropertyName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentId") + .HasColumnType("bigint") + .HasColumnName("agent_id") + .HasJsonPropertyName("agent_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasJsonPropertyName("created_at"); + + b.Property("CurrentSessionId") + .HasColumnType("bigint") + .HasColumnName("current_session_id") + .HasJsonPropertyName("current_session_id"); + + b.Property("DebounceMs") + .HasColumnType("integer") + .HasColumnName("debounce_ms") + .HasJsonPropertyName("debounce_ms"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled") + .HasJsonPropertyName("enabled"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("external_id") + .HasJsonPropertyName("external_id"); + + b.Property("GroupContextMessages") + .HasColumnType("integer") + .HasColumnName("group_context_messages") + .HasJsonPropertyName("group_context_messages"); + + b.Property("GroupTopic") + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("group_topic") + .HasJsonPropertyName("group_topic"); + + b.Property("IsGroup") + .HasColumnType("boolean") + .HasColumnName("is_group") + .HasJsonPropertyName("is_group"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)") + .HasColumnName("name") + .HasJsonPropertyName("name"); + + b.Property("OpenGroup") + .HasColumnType("boolean") + .HasColumnName("open_group") + .HasJsonPropertyName("open_group"); + + b.Property("RequireMention") + .HasColumnType("boolean") + .HasColumnName("require_mention") + .HasJsonPropertyName("require_mention"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasJsonPropertyName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("AgentId"); + + b.HasIndex("CurrentSessionId"); + + b.HasIndex("ExternalId") + .IsUnique(); + + b.ToTable("chat"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.ContactEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasJsonPropertyName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasJsonPropertyName("created_at"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)") + .HasColumnName("name") + .HasJsonPropertyName("name"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasJsonPropertyName("updated_at"); + + b.HasKey("Id"); + + b.ToTable("contact"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.ContactIdentifierEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasJsonPropertyName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)") + .HasColumnName("channel") + .HasJsonPropertyName("channel"); + + b.Property("ContactId") + .HasColumnType("bigint") + .HasColumnName("contact_id") + .HasJsonPropertyName("contact_id"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("external_id") + .HasJsonPropertyName("external_id"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("Channel", "ExternalId") + .IsUnique(); + + b.ToTable("contact_identifier"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.CronJobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasJsonPropertyName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChatId") + .HasColumnType("bigint") + .HasColumnName("chat_id") + .HasJsonPropertyName("chat_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasJsonPropertyName("created_at"); + + b.Property("CronExpression") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("cron_expression") + .HasJsonPropertyName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled") + .HasJsonPropertyName("enabled"); + + b.Property("Handler") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)") + .HasColumnName("handler") + .HasJsonPropertyName("handler"); + + b.Property("IsDeterministic") + .HasColumnType("boolean") + .HasColumnName("is_deterministic") + .HasJsonPropertyName("is_deterministic"); + + b.Property("LastRunAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_run_at") + .HasJsonPropertyName("last_run_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)") + .HasColumnName("name") + .HasJsonPropertyName("name"); + + b.Property("NextRunAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_run_at") + .HasJsonPropertyName("next_run_at"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasJsonPropertyName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("ChatId"); + + b.HasIndex("Enabled", "NextRunAt"); + + b.ToTable("cron_job"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.ExecAllowlistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasJsonPropertyName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentId") + .HasColumnType("bigint") + .HasColumnName("agent_id") + .HasJsonPropertyName("agent_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasJsonPropertyName("created_at"); + + b.Property("LastCommand") + .HasColumnType("text") + .HasColumnName("last_command") + .HasJsonPropertyName("last_command"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at") + .HasJsonPropertyName("last_used_at"); + + b.Property("Pattern") + .IsRequired() + .HasColumnType("text") + .HasColumnName("pattern") + .HasJsonPropertyName("pattern"); + + b.HasKey("Id"); + + b.HasIndex("AgentId", "Pattern") + .IsUnique(); + + b.ToTable("exec_allowlist"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.ExecApprovalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasJsonPropertyName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentId") + .HasColumnType("bigint") + .HasColumnName("agent_id") + .HasJsonPropertyName("agent_id"); + + b.Property("ApprovalId") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("varchar(16)") + .HasColumnName("approval_id") + .HasJsonPropertyName("approval_id"); + + b.Property("ChatId") + .HasColumnType("bigint") + .HasColumnName("chat_id") + .HasJsonPropertyName("chat_id"); + + b.Property("Command") + .IsRequired() + .HasColumnType("text") + .HasColumnName("command") + .HasJsonPropertyName("command"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasJsonPropertyName("created_at"); + + b.Property("Cwd") + .HasColumnType("text") + .HasColumnName("cwd") + .HasJsonPropertyName("cwd"); + + b.Property("Decision") + .HasMaxLength(16) + .HasColumnType("varchar(16)") + .HasColumnName("decision") + .HasJsonPropertyName("decision"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at") + .HasJsonPropertyName("expires_at"); + + b.Property("MessageExternalId") + .HasMaxLength(128) + .HasColumnType("varchar(128)") + .HasColumnName("message_external_id") + .HasJsonPropertyName("message_external_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at") + .HasJsonPropertyName("resolved_at"); + + b.Property("ResolvedBy") + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("resolved_by") + .HasJsonPropertyName("resolved_by"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("varchar(16)") + .HasColumnName("status") + .HasJsonPropertyName("status"); + + b.HasKey("Id"); + + b.HasIndex("AgentId"); + + b.HasIndex("ApprovalId") + .IsUnique(); + + b.HasIndex("ChatId"); + + b.HasIndex("MessageExternalId"); + + b.HasIndex("Status") + .HasFilter("status = 'pending'"); + + b.ToTable("exec_approval"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.MemoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasJsonPropertyName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("bigint") + .HasColumnName("contact_id") + .HasJsonPropertyName("contact_id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content") + .HasJsonPropertyName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasJsonPropertyName("created_at"); + + b.Property("Embedding") + .HasColumnType("vector(1536)") + .HasColumnName("embedding"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasJsonPropertyName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("Embedding"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" }); + + b.ToTable("memory"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasJsonPropertyName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .HasColumnType("text") + .HasColumnName("body") + .HasJsonPropertyName("body"); + + b.Property("CacheCreationTokens") + .HasColumnType("integer") + .HasColumnName("cache_creation_tokens") + .HasJsonPropertyName("cache_creation_tokens"); + + b.Property("CacheReadTokens") + .HasColumnType("integer") + .HasColumnName("cache_read_tokens") + .HasJsonPropertyName("cache_read_tokens"); + + b.Property("ChatId") + .HasColumnType("bigint") + .HasColumnName("chat_id") + .HasJsonPropertyName("chat_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasJsonPropertyName("created_at"); + + b.Property("ExternalId") + .HasMaxLength(128) + .HasColumnType("varchar(128)") + .HasColumnName("external_id") + .HasJsonPropertyName("external_id"); + + b.Property("InputTokens") + .HasColumnType("integer") + .HasColumnName("input_tokens") + .HasJsonPropertyName("input_tokens"); + + b.Property("IsFromMe") + .HasColumnType("boolean") + .HasColumnName("is_from_me") + .HasJsonPropertyName("is_from_me"); + + b.Property("MediaCaption") + .HasColumnType("text") + .HasColumnName("media_caption") + .HasJsonPropertyName("media_caption"); + + b.Property("MediaData") + .HasColumnType("bytea") + .HasColumnName("media_data"); + + b.Property("MediaMimeType") + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("media_mime_type") + .HasJsonPropertyName("media_mime_type"); + + b.Property("MediaType") + .HasMaxLength(32) + .HasColumnType("varchar(32)") + .HasColumnName("media_type") + .HasJsonPropertyName("media_type"); + + b.Property("OutputTokens") + .HasColumnType("integer") + .HasColumnName("output_tokens") + .HasJsonPropertyName("output_tokens"); + + b.Property("Queued") + .HasColumnType("boolean") + .HasColumnName("queued") + .HasJsonPropertyName("queued"); + + b.Property("ReplyContent") + .HasColumnType("text") + .HasColumnName("reply_content") + .HasJsonPropertyName("reply_content"); + + b.Property("ReplyToId") + .HasMaxLength(128) + .HasColumnType("varchar(128)") + .HasColumnName("reply_to_id") + .HasJsonPropertyName("reply_to_id"); + + b.Property("Role") + .HasMaxLength(16) + .HasColumnType("varchar(16)") + .HasColumnName("role") + .HasJsonPropertyName("role"); + + b.Property("SenderId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("sender_id") + .HasJsonPropertyName("sender_id"); + + b.Property("SenderName") + .HasMaxLength(256) + .HasColumnType("varchar(256)") + .HasColumnName("sender_name") + .HasJsonPropertyName("sender_name"); + + b.Property("SessionId") + .HasColumnType("bigint") + .HasColumnName("session_id") + .HasJsonPropertyName("session_id"); + + b.Property("SkContent") + .HasColumnType("jsonb") + .HasColumnName("sk_content") + .HasJsonPropertyName("sk_content"); + + b.Property("ThinkingTokens") + .HasColumnType("integer") + .HasColumnName("thinking_tokens") + .HasJsonPropertyName("thinking_tokens"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp") + .HasJsonPropertyName("timestamp"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique() + .HasFilter("\"external_id\" IS NOT NULL"); + + b.HasIndex("ChatId", "Timestamp"); + + b.HasIndex("SenderId", "Id"); + + b.HasIndex("SenderName", "Id"); + + b.HasIndex("SessionId", "Timestamp"); + + b.ToTable("message"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.PromptSectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasJsonPropertyName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentId") + .HasColumnType("bigint") + .HasColumnName("agent_id") + .HasJsonPropertyName("agent_id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content") + .HasJsonPropertyName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasJsonPropertyName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled") + .HasJsonPropertyName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("name") + .HasJsonPropertyName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order") + .HasJsonPropertyName("sort_order"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasJsonPropertyName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("AgentId", "Name") + .IsUnique(); + + b.ToTable("prompt_section"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.SessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasJsonPropertyName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChatId") + .HasColumnType("bigint") + .HasColumnName("chat_id") + .HasJsonPropertyName("chat_id"); + + b.Property("ContextTokens") + .HasColumnType("bigint") + .HasColumnName("context_tokens") + .HasJsonPropertyName("context_tokens"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasJsonPropertyName("created_at"); + + b.Property("IsCompacting") + .HasColumnType("boolean") + .HasColumnName("is_compacting") + .HasJsonPropertyName("is_compacting"); + + b.Property("ParentSessionId") + .HasColumnType("bigint") + .HasColumnName("parent_session_id") + .HasJsonPropertyName("parent_session_id"); + + b.Property("Summary") + .HasColumnType("text") + .HasColumnName("summary") + .HasJsonPropertyName("summary"); + + b.Property("SummaryEmbedding") + .HasColumnType("vector(1536)") + .HasColumnName("summary_embedding") + .HasJsonPropertyName("summary_embedding"); + + b.Property("ToolsPrunedThroughId") + .HasColumnType("bigint") + .HasColumnName("tools_pruned_through_id") + .HasJsonPropertyName("tools_pruned_through_id"); + + b.Property("TotalCacheCreationTokens") + .HasColumnType("bigint") + .HasColumnName("total_cache_creation_tokens") + .HasJsonPropertyName("total_cache_creation_tokens"); + + b.Property("TotalCacheReadTokens") + .HasColumnType("bigint") + .HasColumnName("total_cache_read_tokens") + .HasJsonPropertyName("total_cache_read_tokens"); + + b.Property("TotalInputTokens") + .HasColumnType("bigint") + .HasColumnName("total_input_tokens") + .HasJsonPropertyName("total_input_tokens"); + + b.Property("TotalOutputTokens") + .HasColumnType("bigint") + .HasColumnName("total_output_tokens") + .HasJsonPropertyName("total_output_tokens"); + + b.Property("TotalThinkingTokens") + .HasColumnType("bigint") + .HasColumnName("total_thinking_tokens") + .HasJsonPropertyName("total_thinking_tokens"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasJsonPropertyName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("ChatId"); + + b.HasIndex("ParentSessionId"); + + b.HasIndex("SummaryEmbedding"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SummaryEmbedding"), "hnsw"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("SummaryEmbedding"), new[] { "vector_cosine_ops" }); + + b.ToTable("session"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.ChatAllowedSenderEntity", b => + { + b.HasOne("Lis.Persistence.Entities.ChatEntity", "Chat") + .WithMany("AllowedSenders") + .HasForeignKey("ChatId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chat"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.ChatEntity", b => + { + b.HasOne("Lis.Persistence.Entities.AgentEntity", "Agent") + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Lis.Persistence.Entities.SessionEntity", "CurrentSession") + .WithMany() + .HasForeignKey("CurrentSessionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Agent"); + + b.Navigation("CurrentSession"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.ContactIdentifierEntity", b => + { + b.HasOne("Lis.Persistence.Entities.ContactEntity", "Contact") + .WithMany("Identifiers") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.CronJobEntity", b => + { + b.HasOne("Lis.Persistence.Entities.ChatEntity", "Chat") + .WithMany() + .HasForeignKey("ChatId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chat"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.ExecAllowlistEntity", b => + { + b.HasOne("Lis.Persistence.Entities.AgentEntity", "Agent") + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Agent"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.ExecApprovalEntity", b => + { + b.HasOne("Lis.Persistence.Entities.AgentEntity", "Agent") + .WithMany() + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Lis.Persistence.Entities.ChatEntity", "Chat") + .WithMany() + .HasForeignKey("ChatId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Agent"); + + b.Navigation("Chat"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.MemoryEntity", b => + { + b.HasOne("Lis.Persistence.Entities.ContactEntity", "Contact") + .WithMany("Memories") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.MessageEntity", b => + { + b.HasOne("Lis.Persistence.Entities.ChatEntity", "Chat") + .WithMany("Messages") + .HasForeignKey("ChatId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lis.Persistence.Entities.SessionEntity", "Session") + .WithMany() + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chat"); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.PromptSectionEntity", b => + { + b.HasOne("Lis.Persistence.Entities.AgentEntity", "Agent") + .WithMany("PromptSections") + .HasForeignKey("AgentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Agent"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.SessionEntity", b => + { + b.HasOne("Lis.Persistence.Entities.ChatEntity", "Chat") + .WithMany() + .HasForeignKey("ChatId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lis.Persistence.Entities.SessionEntity", "ParentSession") + .WithMany() + .HasForeignKey("ParentSessionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Chat"); + + b.Navigation("ParentSession"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.AgentEntity", b => + { + b.Navigation("PromptSections"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.ChatEntity", b => + { + b.Navigation("AllowedSenders"); + + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Lis.Persistence.Entities.ContactEntity", b => + { + b.Navigation("Identifiers"); + + b.Navigation("Memories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Lis.Persistence/Migrations/20260322144800_add_cron_jobs.cs b/Lis.Persistence/Migrations/20260322144800_add_cron_jobs.cs new file mode 100644 index 0000000..f850f91 --- /dev/null +++ b/Lis.Persistence/Migrations/20260322144800_add_cron_jobs.cs @@ -0,0 +1,61 @@ +ο»Ώusing System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Lis.Persistence.Migrations +{ + /// + public partial class add_cron_jobs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "cron_job", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "varchar(128)", maxLength: 128, nullable: false), + cron_expression = table.Column(type: "varchar(64)", maxLength: 64, nullable: false), + handler = table.Column(type: "varchar(256)", maxLength: 256, nullable: false), + chat_id = table.Column(type: "bigint", nullable: false), + is_deterministic = table.Column(type: "boolean", nullable: false), + enabled = table.Column(type: "boolean", nullable: false), + next_run_at = table.Column(type: "timestamp with time zone", nullable: false), + last_run_at = table.Column(type: "timestamp with time zone", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_cron_job", x => x.id); + table.ForeignKey( + name: "FK_cron_job_chat_chat_id", + column: x => x.chat_id, + principalTable: "chat", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_cron_job_chat_id", + table: "cron_job", + column: "chat_id"); + + migrationBuilder.CreateIndex( + name: "IX_cron_job_enabled_next_run_at", + table: "cron_job", + columns: new[] { "enabled", "next_run_at" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "cron_job"); + } + } +} diff --git a/Lis.Persistence/Migrations/LisDbContextModelSnapshot.cs b/Lis.Persistence/Migrations/LisDbContextModelSnapshot.cs index de02528..4f1da1b 100644 --- a/Lis.Persistence/Migrations/LisDbContextModelSnapshot.cs +++ b/Lis.Persistence/Migrations/LisDbContextModelSnapshot.cs @@ -361,6 +361,81 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("contact_identifier"); }); + modelBuilder.Entity("Lis.Persistence.Entities.CronJobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasJsonPropertyName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChatId") + .HasColumnType("bigint") + .HasColumnName("chat_id") + .HasJsonPropertyName("chat_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasJsonPropertyName("created_at"); + + b.Property("CronExpression") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("cron_expression") + .HasJsonPropertyName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled") + .HasJsonPropertyName("enabled"); + + b.Property("Handler") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)") + .HasColumnName("handler") + .HasJsonPropertyName("handler"); + + b.Property("IsDeterministic") + .HasColumnType("boolean") + .HasColumnName("is_deterministic") + .HasJsonPropertyName("is_deterministic"); + + b.Property("LastRunAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_run_at") + .HasJsonPropertyName("last_run_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)") + .HasColumnName("name") + .HasJsonPropertyName("name"); + + b.Property("NextRunAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_run_at") + .HasJsonPropertyName("next_run_at"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasJsonPropertyName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("ChatId"); + + b.HasIndex("Enabled", "NextRunAt"); + + b.ToTable("cron_job"); + }); + modelBuilder.Entity("Lis.Persistence.Entities.ExecAllowlistEntity", b => { b.Property("Id") @@ -885,6 +960,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Contact"); }); + modelBuilder.Entity("Lis.Persistence.Entities.CronJobEntity", b => + { + b.HasOne("Lis.Persistence.Entities.ChatEntity", "Chat") + .WithMany() + .HasForeignKey("ChatId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chat"); + }); + modelBuilder.Entity("Lis.Persistence.Entities.ExecAllowlistEntity", b => { b.HasOne("Lis.Persistence.Entities.AgentEntity", "Agent") diff --git a/Lis.Tests/Agent/CronCommandTests.cs b/Lis.Tests/Agent/CronCommandTests.cs new file mode 100644 index 0000000..db327d7 --- /dev/null +++ b/Lis.Tests/Agent/CronCommandTests.cs @@ -0,0 +1,227 @@ +using Lis.Agent.Commands; +using Lis.Core.Channel; +using Lis.Persistence; +using Lis.Persistence.Entities; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Lis.Tests.Agent; + +public class CronCommandTests : IDisposable { + private readonly LisDbContext _db; + private readonly CronCommand _sut; + + public CronCommandTests() { + DbContextOptions options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + this._db = new TestDbContext(options); + this._sut = new CronCommand(); + } + + private sealed class TestDbContext(DbContextOptions options) : LisDbContext(options) { + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity().Ignore(e => e.Embedding); + modelBuilder.Entity().Ignore(e => e.SummaryEmbedding); + } + } + + public void Dispose() { + this._db.Dispose(); + GC.SuppressFinalize(this); + } + + private async Task<(ChatEntity Chat, AgentEntity Agent)> SeedDataAsync() { + AgentEntity agent = new() { + Name = "default", DisplayName = "Lis", Model = "test-model", IsDefault = true, + CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow + }; + this._db.Agents.Add(agent); + await this._db.SaveChangesAsync(); + + ChatEntity chat = new() { + ExternalId = "test-chat@jid", + Name = "Test Chat", + Enabled = true, + AgentId = agent.Id, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + this._db.Chats.Add(chat); + await this._db.SaveChangesAsync(); + + return (chat, agent); + } + + private CommandContext CreateContext(ChatEntity chat, AgentEntity agent, string? args) { + IncomingMessage msg = new() { + ExternalId = "m1", + ChatId = chat.ExternalId, + SenderId = "owner@jid", + Body = args is not null ? $"/cron {args}" : "/cron" + }; + return new CommandContext(msg, chat, null, this._db, agent, args); + } + + // ── Triggers ──────────────────────────────────────────────────────── + + [Fact] + public void Triggers_ContainsCron() { + Assert.Contains("/cron", this._sut.Triggers); + } + + [Fact] + public void OwnerOnly_IsTrue() { + Assert.True(this._sut.OwnerOnly); + } + + // ── No Args ───────────────────────────────────────────────────────── + + [Fact] + public async Task Execute_NoArgs_ReturnsUsage() { + (ChatEntity chat, AgentEntity agent) = await this.SeedDataAsync(); + CommandContext ctx = this.CreateContext(chat, agent, null); + + string result = await this._sut.ExecuteAsync(ctx, CancellationToken.None); + + Assert.Contains("Usage", result, StringComparison.OrdinalIgnoreCase); + } + + // ── Add ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Execute_Add_ValidCron_CreatesJob() { + (ChatEntity chat, AgentEntity agent) = await this.SeedDataAsync(); + CommandContext ctx = this.CreateContext(chat, agent, "add \"*/5 * * * *\" test_handler My Test Job"); + + string result = await this._sut.ExecuteAsync(ctx, CancellationToken.None); + + Assert.Contains("created", result, StringComparison.OrdinalIgnoreCase); + + CronJobEntity? job = await this._db.CronJobs.FirstOrDefaultAsync(); + Assert.NotNull(job); + Assert.Equal("My Test Job", job.Name); + Assert.Equal("*/5 * * * *", job.CronExpression); + Assert.Equal("test_handler", job.Handler); + Assert.Equal(chat.Id, job.ChatId); + Assert.True(job.Enabled); + } + + [Fact] + public async Task Execute_Add_InvalidCron_ReturnsError() { + (ChatEntity chat, AgentEntity agent) = await this.SeedDataAsync(); + CommandContext ctx = this.CreateContext(chat, agent, "add \"invalid\" test_handler My Job"); + + string result = await this._sut.ExecuteAsync(ctx, CancellationToken.None); + + Assert.Contains("invalid", result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Execute_Add_MissingArgs_ReturnsUsage() { + (ChatEntity chat, AgentEntity agent) = await this.SeedDataAsync(); + CommandContext ctx = this.CreateContext(chat, agent, "add"); + + string result = await this._sut.ExecuteAsync(ctx, CancellationToken.None); + + Assert.Contains("Usage", result, StringComparison.OrdinalIgnoreCase); + } + + // ── List ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Execute_List_NoJobs_ReturnsEmpty() { + (ChatEntity chat, AgentEntity agent) = await this.SeedDataAsync(); + CommandContext ctx = this.CreateContext(chat, agent, "list"); + + string result = await this._sut.ExecuteAsync(ctx, CancellationToken.None); + + Assert.Contains("no cron jobs", result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Execute_List_WithJobs_ListsThem() { + (ChatEntity chat, AgentEntity agent) = await this.SeedDataAsync(); + + this._db.CronJobs.Add(new CronJobEntity { + Name = "Daily Backup", + CronExpression = "0 2 * * *", + Handler = "backup_handler", + ChatId = chat.Id, + Enabled = true, + NextRunAt = DateTimeOffset.UtcNow.AddHours(1), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + await this._db.SaveChangesAsync(); + + CommandContext ctx = this.CreateContext(chat, agent, "list"); + + string result = await this._sut.ExecuteAsync(ctx, CancellationToken.None); + + Assert.Contains("Daily Backup", result); + Assert.Contains("0 2 * * *", result); + } + + // ── Remove ────────────────────────────────────────────────────────── + + [Fact] + public async Task Execute_Remove_ExistingJob_RemovesIt() { + (ChatEntity chat, AgentEntity agent) = await this.SeedDataAsync(); + + CronJobEntity job = new() { + Name = "To Remove", + CronExpression = "0 2 * * *", + Handler = "handler", + ChatId = chat.Id, + Enabled = true, + NextRunAt = DateTimeOffset.UtcNow.AddHours(1), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + this._db.CronJobs.Add(job); + await this._db.SaveChangesAsync(); + + CommandContext ctx = this.CreateContext(chat, agent, $"remove {job.Id}"); + + string result = await this._sut.ExecuteAsync(ctx, CancellationToken.None); + + Assert.Contains("removed", result, StringComparison.OrdinalIgnoreCase); + Assert.Null(await this._db.CronJobs.FindAsync(job.Id)); + } + + [Fact] + public async Task Execute_Remove_NonExistentJob_ReturnsNotFound() { + (ChatEntity chat, AgentEntity agent) = await this.SeedDataAsync(); + CommandContext ctx = this.CreateContext(chat, agent, "remove 9999"); + + string result = await this._sut.ExecuteAsync(ctx, CancellationToken.None); + + Assert.Contains("not found", result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Execute_Remove_InvalidId_ReturnsError() { + (ChatEntity chat, AgentEntity agent) = await this.SeedDataAsync(); + CommandContext ctx = this.CreateContext(chat, agent, "remove abc"); + + string result = await this._sut.ExecuteAsync(ctx, CancellationToken.None); + + Assert.Contains("invalid", result, StringComparison.OrdinalIgnoreCase); + } + + // ── Unknown Subcommand ────────────────────────────────────────────── + + [Fact] + public async Task Execute_UnknownSubcommand_ReturnsUsage() { + (ChatEntity chat, AgentEntity agent) = await this.SeedDataAsync(); + CommandContext ctx = this.CreateContext(chat, agent, "unknown"); + + string result = await this._sut.ExecuteAsync(ctx, CancellationToken.None); + + Assert.Contains("Usage", result, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Lis.Tests/Agent/CronPluginTests.cs b/Lis.Tests/Agent/CronPluginTests.cs new file mode 100644 index 0000000..f89dac2 --- /dev/null +++ b/Lis.Tests/Agent/CronPluginTests.cs @@ -0,0 +1,173 @@ +using Lis.Core.Util; +using Lis.Persistence; +using Lis.Persistence.Entities; +using Lis.Tools; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; + +using Moq; + +namespace Lis.Tests.Agent; + +public class CronPluginTests : IDisposable { + private readonly LisDbContext _db; + private readonly CronPlugin _sut; + + public CronPluginTests() { + DbContextOptions options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + this._db = new TestDbContext(options); + + Mock scopeFactory = new(); + Mock scope = new(); + Mock provider = new(); + scope.Setup(s => s.ServiceProvider).Returns(provider.Object); + scopeFactory.Setup(f => f.CreateScope()).Returns(scope.Object); + provider.Setup(p => p.GetService(typeof(LisDbContext))).Returns(this._db); + + this._sut = new CronPlugin(scopeFactory.Object); + } + + private sealed class TestDbContext(DbContextOptions options) : LisDbContext(options) { + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity().Ignore(e => e.Embedding); + modelBuilder.Entity().Ignore(e => e.SummaryEmbedding); + } + } + + public void Dispose() { + this._db.Dispose(); + GC.SuppressFinalize(this); + } + + private async Task SeedChatAsync() { + ChatEntity chat = new() { + ExternalId = "test-chat@jid", + Name = "Test Chat", + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + this._db.Chats.Add(chat); + await this._db.SaveChangesAsync(); + return chat; + } + + // ── Add ───────────────────────────────────────────────────────────── + + [Fact] + public async Task CronAdd_ValidInput_CreatesJob() { + ChatEntity chat = await this.SeedChatAsync(); + ToolContext.ChatId = chat.ExternalId; + + string result = await this._sut.CronAddAsync( + "*/5 * * * *", "test_handler", "My Job"); + + Assert.Contains("created", result, StringComparison.OrdinalIgnoreCase); + + CronJobEntity? job = await this._db.CronJobs.FirstOrDefaultAsync(); + Assert.NotNull(job); + Assert.Equal("My Job", job.Name); + Assert.Equal("*/5 * * * *", job.CronExpression); + Assert.Equal("test_handler", job.Handler); + Assert.True(job.IsDeterministic); + } + + [Fact] + public async Task CronAdd_NonDeterministic_SetsFlag() { + ChatEntity chat = await this.SeedChatAsync(); + ToolContext.ChatId = chat.ExternalId; + + string result = await this._sut.CronAddAsync( + "0 9 * * *", "ai_handler", "AI Job", isDeterministic: false); + + CronJobEntity? job = await this._db.CronJobs.FirstOrDefaultAsync(); + Assert.NotNull(job); + Assert.False(job.IsDeterministic); + } + + [Fact] + public async Task CronAdd_InvalidCron_ReturnsError() { + ChatEntity chat = await this.SeedChatAsync(); + ToolContext.ChatId = chat.ExternalId; + + string result = await this._sut.CronAddAsync( + "invalid", "handler", "Bad Job"); + + Assert.Contains("invalid", result, StringComparison.OrdinalIgnoreCase); + } + + // ── List ──────────────────────────────────────────────────────────── + + [Fact] + public async Task CronList_NoJobs_ReturnsEmpty() { + ChatEntity chat = await this.SeedChatAsync(); + ToolContext.ChatId = chat.ExternalId; + + string result = await this._sut.CronListAsync(); + + Assert.Contains("no cron jobs", result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CronList_WithJobs_ListsThem() { + ChatEntity chat = await this.SeedChatAsync(); + ToolContext.ChatId = chat.ExternalId; + + this._db.CronJobs.Add(new CronJobEntity { + Name = "Daily Backup", + CronExpression = "0 2 * * *", + Handler = "backup_handler", + ChatId = chat.Id, + Enabled = true, + NextRunAt = DateTimeOffset.UtcNow.AddHours(1), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + await this._db.SaveChangesAsync(); + + string result = await this._sut.CronListAsync(); + + Assert.Contains("Daily Backup", result); + Assert.Contains("0 2 * * *", result); + Assert.Contains("backup_handler", result); + } + + // ── Remove ────────────────────────────────────────────────────────── + + [Fact] + public async Task CronRemove_ExistingJob_RemovesIt() { + ChatEntity chat = await this.SeedChatAsync(); + ToolContext.ChatId = chat.ExternalId; + + CronJobEntity job = new() { + Name = "To Remove", + CronExpression = "0 2 * * *", + Handler = "handler", + ChatId = chat.Id, + Enabled = true, + NextRunAt = DateTimeOffset.UtcNow.AddHours(1), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + this._db.CronJobs.Add(job); + await this._db.SaveChangesAsync(); + + string result = await this._sut.CronRemoveAsync(job.Id); + + Assert.Contains("removed", result, StringComparison.OrdinalIgnoreCase); + Assert.Null(await this._db.CronJobs.FindAsync(job.Id)); + } + + [Fact] + public async Task CronRemove_NonExistent_ReturnsNotFound() { + string result = await this._sut.CronRemoveAsync(9999); + + Assert.Contains("not found", result, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Lis.Tests/Agent/CronServiceTests.cs b/Lis.Tests/Agent/CronServiceTests.cs new file mode 100644 index 0000000..1ba6b1e --- /dev/null +++ b/Lis.Tests/Agent/CronServiceTests.cs @@ -0,0 +1,248 @@ +using Cronos; + +using Lis.Agent; +using Lis.Core.Channel; +using Lis.Core.Cron; +using Lis.Persistence; +using Lis.Persistence.Entities; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +using Moq; + +namespace Lis.Tests.Agent; + +public class CronServiceTests : IDisposable { + private readonly LisDbContext _db; + private readonly Mock _scopeFactory; + private readonly Mock _scope; + private readonly Mock _scopeProvider; + private readonly Mock _channelClient; + private readonly CronService _sut; + + public CronServiceTests() { + DbContextOptions options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + this._db = new TestDbContext(options); + + this._scopeFactory = new Mock(); + this._scope = new Mock(); + this._scopeProvider = new Mock(); + this._channelClient = new Mock(); + + this._scope.Setup(s => s.ServiceProvider).Returns(this._scopeProvider.Object); + this._scopeFactory.Setup(f => f.CreateScope()).Returns(this._scope.Object); + this._scopeProvider.Setup(p => p.GetService(typeof(LisDbContext))).Returns(this._db); + this._scopeProvider.Setup(p => p.GetService(typeof(IChannelClient))).Returns(this._channelClient.Object); + + this._sut = new CronService( + this._scopeFactory.Object, + [], + NullLogger.Instance); + } + + private sealed class TestDbContext(DbContextOptions options) : LisDbContext(options) { + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity().Ignore(e => e.Embedding); + modelBuilder.Entity().Ignore(e => e.SummaryEmbedding); + } + } + + public void Dispose() { + this._db.Dispose(); + GC.SuppressFinalize(this); + } + + private async Task SeedChatAsync() { + ChatEntity chat = new() { + ExternalId = "test-chat@jid", + Name = "Test Chat", + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + this._db.Chats.Add(chat); + await this._db.SaveChangesAsync(); + return chat; + } + + // ── Cron Expression Parsing ───────────────────────────────────────── + + [Theory] + [InlineData("*/5 * * * *")] // Every 5 minutes + [InlineData("0 9 * * *")] // Daily at 9am + [InlineData("0 0 * * 1")] // Weekly on Monday + [InlineData("30 14 1 * *")] // Monthly on 1st at 14:30 + public void ParseCronExpression_ValidExpression_Succeeds(string expression) { + CronExpression cron = CronExpression.Parse(expression); + DateTimeOffset? next = cron.GetNextOccurrence(DateTimeOffset.UtcNow, TimeZoneInfo.Utc); + + Assert.NotNull(next); + Assert.True(next > DateTimeOffset.UtcNow); + } + + [Theory] + [InlineData("invalid")] + [InlineData("")] + [InlineData("* * *")] + public void ParseCronExpression_InvalidExpression_Throws(string expression) { + Assert.ThrowsAny(() => CronExpression.Parse(expression)); + } + + // ── Next Run Calculation ──────────────────────────────────────────── + + [Fact] + public void CalculateNextRun_EveryMinute_ReturnsWithinOneMinute() { + CronExpression cron = CronExpression.Parse("* * * * *"); + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset? next = cron.GetNextOccurrence(now, TimeZoneInfo.Utc); + + Assert.NotNull(next); + Assert.True((next.Value - now).TotalMinutes <= 1); + } + + [Fact] + public void CalculateNextRun_DailyAt9_ReturnsCorrectHour() { + CronExpression cron = CronExpression.Parse("0 9 * * *"); + DateTimeOffset baseTime = new(2026, 3, 22, 0, 0, 0, TimeSpan.Zero); + DateTimeOffset? next = cron.GetNextOccurrence(baseTime, TimeZoneInfo.Utc); + + Assert.NotNull(next); + Assert.Equal(9, next.Value.Hour); + Assert.Equal(0, next.Value.Minute); + } + + // ── Due Job Detection ─────────────────────────────────────────────── + + [Fact] + public async Task GetDueJobs_ReturnsDueEnabledJobs() { + ChatEntity chat = await this.SeedChatAsync(); + + this._db.CronJobs.Add(new CronJobEntity { + Name = "due-job", + CronExpression = "* * * * *", + Handler = "test_handler", + ChatId = chat.Id, + Enabled = true, + NextRunAt = DateTimeOffset.UtcNow.AddMinutes(-1), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + this._db.CronJobs.Add(new CronJobEntity { + Name = "future-job", + CronExpression = "* * * * *", + Handler = "test_handler", + ChatId = chat.Id, + Enabled = true, + NextRunAt = DateTimeOffset.UtcNow.AddHours(1), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + this._db.CronJobs.Add(new CronJobEntity { + Name = "disabled-due-job", + CronExpression = "* * * * *", + Handler = "test_handler", + ChatId = chat.Id, + Enabled = false, + NextRunAt = DateTimeOffset.UtcNow.AddMinutes(-1), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + await this._db.SaveChangesAsync(); + + List dueJobs = await this._db.CronJobs + .Where(j => j.Enabled && j.NextRunAt <= DateTimeOffset.UtcNow) + .ToListAsync(); + + Assert.Single(dueJobs); + Assert.Equal("due-job", dueJobs[0].Name); + } + + [Fact] + public async Task GetDueJobs_NoDueJobs_ReturnsEmpty() { + ChatEntity chat = await this.SeedChatAsync(); + + this._db.CronJobs.Add(new CronJobEntity { + Name = "future-job", + CronExpression = "* * * * *", + Handler = "test_handler", + ChatId = chat.Id, + Enabled = true, + NextRunAt = DateTimeOffset.UtcNow.AddHours(1), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }); + await this._db.SaveChangesAsync(); + + List dueJobs = await this._db.CronJobs + .Where(j => j.Enabled && j.NextRunAt <= DateTimeOffset.UtcNow) + .ToListAsync(); + + Assert.Empty(dueJobs); + } + + // ── Job Update After Execution ────────────────────────────────────── + + [Fact] + public async Task UpdateJobAfterExecution_SetsLastRunAndNextRun() { + ChatEntity chat = await this.SeedChatAsync(); + DateTimeOffset now = DateTimeOffset.UtcNow; + + CronJobEntity job = new() { + Name = "test-job", + CronExpression = "0 9 * * *", + Handler = "test_handler", + ChatId = chat.Id, + Enabled = true, + NextRunAt = now.AddMinutes(-1), + CreatedAt = now, + UpdatedAt = now + }; + this._db.CronJobs.Add(job); + await this._db.SaveChangesAsync(); + + CronExpression cron = CronExpression.Parse(job.CronExpression); + DateTimeOffset? nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc); + + job.LastRunAt = now; + job.NextRunAt = nextRun ?? now.AddDays(1); + job.UpdatedAt = now; + await this._db.SaveChangesAsync(); + + CronJobEntity? updated = await this._db.CronJobs.FindAsync(job.Id); + Assert.NotNull(updated); + Assert.NotNull(updated.LastRunAt); + Assert.True(updated.NextRunAt > now); + } + + // ── Handler Resolution ────────────────────────────────────────────── + + [Fact] + public void ResolveHandler_KnownHandler_ReturnsHandler() { + Mock handler = new(); + handler.Setup(h => h.HandlerName).Returns("test_handler"); + + CronService service = new( + this._scopeFactory.Object, + [handler.Object], + NullLogger.Instance); + + ICronHandler? resolved = service.ResolveHandler("test_handler"); + + Assert.NotNull(resolved); + Assert.Equal("test_handler", resolved.HandlerName); + } + + [Fact] + public void ResolveHandler_UnknownHandler_ReturnsNull() { + ICronHandler? resolved = this._sut.ResolveHandler("nonexistent"); + + Assert.Null(resolved); + } +} diff --git a/Lis.Tests/Lis.Tests.csproj b/Lis.Tests/Lis.Tests.csproj index 5bf8a22..d675705 100644 --- a/Lis.Tests/Lis.Tests.csproj +++ b/Lis.Tests/Lis.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/Lis.Tools/CronPlugin.cs b/Lis.Tools/CronPlugin.cs new file mode 100644 index 0000000..ef48a04 --- /dev/null +++ b/Lis.Tools/CronPlugin.cs @@ -0,0 +1,122 @@ +using System.ComponentModel; +using System.Text; + +using Cronos; + +using Lis.Core.Util; +using Lis.Persistence; +using Lis.Persistence.Entities; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; + +namespace Lis.Tools; + +public sealed class CronPlugin(IServiceScopeFactory scopeFactory) { + + [KernelFunction("cron_add")] + [Description("Create a new cron job. The cron expression uses standard 5-field format (minute hour day month weekday).")] + [ToolSummarization(SummarizationPolicy.Prune)] + [ToolAuthorization(ToolAuthLevel.OwnerOnly)] + public async Task CronAddAsync( + [Description("Cron expression (5 fields: minute hour day month weekday). Example: '*/5 * * * *' for every 5 minutes.")] string cronExpression, + [Description("Handler identifier β€” what to run when the job triggers.")] string handler, + [Description("Human-readable name for this job.")] string name, + [Description("Whether the job is deterministic (true) or needs AI decision (false). Default: true.")] bool isDeterministic = true) { + await ToolContext.NotifyAsync($"⏰ Creating cron job: {name}\nExpression: {cronExpression}\nHandler: {handler}"); + + CronExpression cron; + try { + cron = CronExpression.Parse(cronExpression); + } catch (Exception ex) { + return $"Invalid cron expression '{cronExpression}': {ex.Message}"; + } + + using IServiceScope scope = scopeFactory.CreateScope(); + LisDbContext db = scope.ServiceProvider.GetRequiredService(); + + string chatExternalId = ToolContext.ChatId + ?? throw new InvalidOperationException("No chat context"); + + ChatEntity chat = await db.Chats.FirstOrDefaultAsync(c => c.ExternalId == chatExternalId) + ?? throw new ArgumentException($"Chat '{chatExternalId}' not found."); + + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset? nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc); + + CronJobEntity job = new() { + Name = name.Trim(), + CronExpression = cronExpression.Trim(), + Handler = handler.Trim(), + ChatId = chat.Id, + IsDeterministic = isDeterministic, + Enabled = true, + NextRunAt = nextRun ?? now.AddDays(1), + CreatedAt = now, + UpdatedAt = now + }; + + db.CronJobs.Add(job); + await db.SaveChangesAsync(); + + return $"βœ… Cron job #{job.Id} '{job.Name}' created. Next run: {job.NextRunAt:yyyy-MM-dd HH:mm} UTC."; + } + + [KernelFunction("cron_list")] + [Description("List all cron jobs for the current chat.")] + [ToolSummarization(SummarizationPolicy.Prune)] + [ToolAuthorization(ToolAuthLevel.OwnerOnly)] + public async Task CronListAsync() { + await ToolContext.NotifyAsync("πŸ“‹ Listing cron jobs"); + + using IServiceScope scope = scopeFactory.CreateScope(); + LisDbContext db = scope.ServiceProvider.GetRequiredService(); + + string chatExternalId = ToolContext.ChatId + ?? throw new InvalidOperationException("No chat context"); + + ChatEntity chat = await db.Chats.FirstOrDefaultAsync(c => c.ExternalId == chatExternalId) + ?? throw new ArgumentException($"Chat '{chatExternalId}' not found."); + + List jobs = await db.CronJobs + .Where(j => j.ChatId == chat.Id) + .OrderBy(j => j.Id) + .ToListAsync(); + + 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}` | handler: {job.Handler} | next: {job.NextRunAt:yyyy-MM-dd HH:mm} UTC | last: {lastRun}"); + } + + return sb.ToString().TrimEnd(); + } + + [KernelFunction("cron_remove")] + [Description("Remove a cron job by its ID.")] + [ToolSummarization(SummarizationPolicy.Prune)] + [ToolAuthorization(ToolAuthLevel.OwnerOnly)] + public async Task CronRemoveAsync( + [Description("The cron job ID to remove.")] long id) { + await ToolContext.NotifyAsync($"πŸ—‘οΈ Removing cron job #{id}"); + + using IServiceScope scope = scopeFactory.CreateScope(); + LisDbContext db = scope.ServiceProvider.GetRequiredService(); + + CronJobEntity? job = await db.CronJobs.FindAsync(id); + if (job is null) + return $"Cron job #{id} not found."; + + string name = job.Name; + db.CronJobs.Remove(job); + await db.SaveChangesAsync(); + + return $"βœ… Cron job #{id} '{name}' removed."; + } +} diff --git a/Lis.Tools/Lis.Tools.csproj b/Lis.Tools/Lis.Tools.csproj index 2d76a2c..8077880 100644 --- a/Lis.Tools/Lis.Tools.csproj +++ b/Lis.Tools/Lis.Tools.csproj @@ -1,6 +1,7 @@ + diff --git a/global.json b/global.json index 058bafa..816dd81 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,6 @@ { "sdk": { - "version": "10.0.103" + "version": "10.0.103", + "rollForward": "latestMinor" } }