diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 90f6770c..7fda04dc 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -1033,11 +1033,14 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub using var ctx = _dbFactory.CreateDbContext(); var rows = await new PrimeScheduleRepository(ctx).ListAsync(); return rows.Select(e => new PrimeScheduleDto( - e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList(); + e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride, e.Kind)).ToList(); } public async Task UpsertPrimeSchedule(PrimeScheduleDto dto) { + var error = PrimeScheduleValidation.Validate(dto); + if (error is not null) throw new HubException(error); + using var ctx = _dbFactory.CreateDbContext(); var repo = new PrimeScheduleRepository(ctx); var existing = await repo.GetAsync(dto.Id); @@ -1048,13 +1051,14 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub TimeOfDay = dto.TimeOfDay, Enabled = dto.Enabled, PromptOverride = dto.PromptOverride, + Kind = dto.Kind, CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow, LastRunAt = existing?.LastRunAt, }; await repo.UpsertAsync(entity); _primeSignal.Signal(); return new PrimeScheduleDto(entity.Id, (int)entity.Days, entity.TimeOfDay, - entity.Enabled, entity.LastRunAt, entity.PromptOverride); + entity.Enabled, entity.LastRunAt, entity.PromptOverride, entity.Kind); } public async Task DeletePrimeSchedule(Guid id) @@ -1072,7 +1076,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub public async Task RunDailyPrepNow() { - var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null); + var schedule = new PrimeScheduleDto( + Guid.Empty, 0, TimeSpan.Zero, true, null, null, + ClaudeDo.Data.Models.PrimeActionKind.FillMyDay); var firedAt = DateTimeOffset.Now; var outcome = await _primeRunner.FireAsync(schedule, Context.ConnectionAborted); await _broadcaster.PrimeFired(Guid.Empty, outcome.Success, outcome.Message, firedAt); diff --git a/src/ClaudeDo.Worker/Prime/PrimeScheduleValidation.cs b/src/ClaudeDo.Worker/Prime/PrimeScheduleValidation.cs new file mode 100644 index 00000000..8160780d --- /dev/null +++ b/src/ClaudeDo.Worker/Prime/PrimeScheduleValidation.cs @@ -0,0 +1,14 @@ +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Worker.Prime; + +public static class PrimeScheduleValidation +{ + /// Returns an error message, or null when the schedule is safe to persist. + public static string? Validate(PrimeScheduleDto dto) + { + if (dto.Kind == PrimeActionKind.Custom && string.IsNullOrWhiteSpace(dto.PromptOverride)) + return "A custom Prime schedule needs a prompt."; + return null; + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs b/tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs index 0a06415a..c9147e50 100644 --- a/tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs @@ -8,7 +8,7 @@ public class NextDueCalculatorTests private static PrimeScheduleDto Schedule( PrimeDays days, TimeSpan time, bool enabled = true, DateTimeOffset? lastRun = null) => - new(Guid.NewGuid(), (int)days, time, enabled, lastRun, null); + new(Guid.NewGuid(), (int)days, time, enabled, lastRun, null, PrimeActionKind.FillMyDay); [Fact] public void Disabled_Schedule_Returns_Null() diff --git a/tests/ClaudeDo.Worker.Tests/Prime/PrimeScheduleValidationTests.cs b/tests/ClaudeDo.Worker.Tests/Prime/PrimeScheduleValidationTests.cs new file mode 100644 index 00000000..ff48d66c --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Prime/PrimeScheduleValidationTests.cs @@ -0,0 +1,38 @@ +using ClaudeDo.Data.Models; +using ClaudeDo.Worker.Prime; + +namespace ClaudeDo.Worker.Tests.Prime; + +public class PrimeScheduleValidationTests +{ + private static PrimeScheduleDto Dto(PrimeActionKind kind, string? prompt) => + new(Guid.NewGuid(), 31, new TimeSpan(7, 0, 0), true, null, prompt, kind); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Custom_without_a_prompt_is_rejected(string? prompt) + { + var error = PrimeScheduleValidation.Validate(Dto(PrimeActionKind.Custom, prompt)); + Assert.NotNull(error); + } + + [Fact] + public void Custom_with_a_prompt_is_accepted() + { + Assert.Null(PrimeScheduleValidation.Validate(Dto(PrimeActionKind.Custom, "do the thing"))); + } + + [Fact] + public void Ping_without_a_prompt_is_accepted() + { + Assert.Null(PrimeScheduleValidation.Validate(Dto(PrimeActionKind.Ping, null))); + } + + [Fact] + public void FillMyDay_without_a_prompt_is_accepted() + { + Assert.Null(PrimeScheduleValidation.Validate(Dto(PrimeActionKind.FillMyDay, null))); + } +}