From fd7f8ac78fde02a213513c6969e7adb774544509 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 3 Jun 2026 16:19:36 +0200 Subject: [PATCH] feat(daily-prep): add set_my_day MCP tool with cap-guard --- .../External/ExternalMcpService.cs | 43 ++++++++++++++++- .../External/ExternalMcpServiceTests.cs | 46 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 011dc98..abf0cd7 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -30,7 +30,9 @@ public sealed record TaskDto( string? CreatedBy, DateTime CreatedAt, DateTime? StartedAt, - DateTime? FinishedAt); + DateTime? FinishedAt, + bool IsMyDay, + int SortOrder); public sealed record WorktreeInfoDto( string Path, string Branch, string HeadCommit, string BaseCommit, @@ -529,6 +531,41 @@ public sealed class ExternalMcpService return new DailyPrepDataDto(maxTasks, candidates, currentMyDay); } + [McpServerTool, Description( + "Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " + + "(use consecutive sortOrder values to keep related tasks together). " + + "Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " + + "clearing (isMyDay=false) is always allowed.")] + public async Task SetMyDay( + string taskId, + bool isMyDay, + int? sortOrder, + CancellationToken cancellationToken) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); + + var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + + if (isMyDay && !task.IsMyDay) + { + var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken); + var max = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks; + var openMyDay = await ctx.Tasks.CountAsync( + t => t.IsMyDay && t.Status == TaskStatus.Idle, cancellationToken); + if (openMyDay >= max) + throw new InvalidOperationException( + $"MyDay limit {max} reached. Clear a task before adding another."); + } + + task.IsMyDay = isMyDay; + if (sortOrder is not null) task.SortOrder = sortOrder.Value; + await ctx.SaveChangesAsync(cancellationToken); + + await _broadcaster.TaskUpdated(taskId); + return ToDto(task); + } + private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new( t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description, t.IsStarred, t.ScheduledFor, t.CreatedAt); @@ -618,7 +655,9 @@ public sealed class ExternalMcpService t.CreatedBy, t.CreatedAt, t.StartedAt, - t.FinishedAt); + t.FinishedAt, + t.IsMyDay, + t.SortOrder); } internal static class DailyPrepFilter diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 09e8200..dfc7e51 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -254,6 +254,15 @@ public sealed class ExternalMcpServiceTests : IDisposable sut.DeleteTask("does-not-exist", CancellationToken.None)); } + private ExternalMcpService NewService() => BuildSut(CreateQueue()); + + private async Task SeedIdleTask(string title = "t") + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, title, TaskStatus.Idle); + return task.Id; + } + private async Task SeedAppSettingsAsync(string? reportExcludedPaths, int dailyPrepMaxTasks = 5) { var settings = new AppSettingsEntity @@ -366,4 +375,41 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.Equal("myday-task", result.CurrentMyDay[0].Id); Assert.Equal(5, result.MaxTasks); } + + [Fact] + public async Task SetMyDay_sets_flag_and_sort_order() + { + var svc = NewService(); + var id = await SeedIdleTask("My task"); + + var dto = await svc.SetMyDay(id, isMyDay: true, sortOrder: 3, CancellationToken.None); + + Assert.True(dto.IsMyDay); + Assert.Equal(3, dto.SortOrder); + } + + [Fact] + public async Task SetMyDay_rejects_when_cap_reached() + { + await SeedAppSettingsAsync(null, dailyPrepMaxTasks: 1); + var svc = NewService(); + var first = await SeedIdleTask("a"); + var second = await SeedIdleTask("b"); + await svc.SetMyDay(first, true, null, CancellationToken.None); + + var ex = await Assert.ThrowsAsync( + () => svc.SetMyDay(second, true, null, CancellationToken.None)); + Assert.Contains("limit", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task SetMyDay_unset_is_always_allowed() + { + var svc = NewService(); + var id = await SeedIdleTask("a"); + await svc.SetMyDay(id, true, null, CancellationToken.None); + + var dto = await svc.SetMyDay(id, false, null, CancellationToken.None); + Assert.False(dto.IsMyDay); + } }