From 59dc1e2357d80f8fc401fd726aab8da630e6473a Mon Sep 17 00:00:00 2001 From: mika kuns Date: Sat, 25 Apr 2026 11:29:58 +0200 Subject: [PATCH] feat(mcp/external): add SetTaskTags --- .../External/ExternalMcpService.cs | 17 +++++++++++ .../External/ExternalMcpServiceTests.cs | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 3f6c955..3db5ef5 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -231,6 +231,23 @@ public sealed class ExternalMcpService await _broadcaster.TaskUpdated(taskId); } + [McpServerTool, Description("Replace the full tag set on an existing task. Missing tag names auto-create. Refuses if the task is Running.")] + public async Task SetTaskTags( + string taskId, + IReadOnlyList tags, + CancellationToken cancellationToken) + { + var task = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (task.Status == TaskStatus.Running) + throw new InvalidOperationException("Cannot retag a running task. Cancel it first."); + + await _tasks.SetTagsAsync(taskId, tags, cancellationToken); + var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; + await _broadcaster.TaskUpdated(taskId); + return ToDto(reload); + } + [McpServerTool, Description("List all known tags. Useful for discovering existing tag names (including 'agent' which marks tasks for auto-execution) before tagging.")] public async Task> ListTags(CancellationToken cancellationToken) { diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 2f40785..4ab79ba 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -261,4 +261,34 @@ public sealed class ExternalMcpServiceTests : IDisposable await Assert.ThrowsAsync(() => sut.DeleteTask("does-not-exist", CancellationToken.None)); } + + [Fact] + public async Task SetTaskTags_ReplacesTagSetAndBroadcasts() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + await _tasks.SetTagsAsync(task.Id, new[] { "agent" }); + var queue = CreateQueue(); + var sut = BuildSut(queue); + + var dto = await sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None); + + var tags = await _tasks.GetTagsAsync(task.Id); + Assert.Single(tags); + Assert.Equal("manual", tags[0].Name); + Assert.Contains(_hub.RecordingClients.Proxy.Calls, + c => c.Method == "TaskUpdated" && (string)c.Args[0]! == task.Id); + } + + [Fact] + public async Task SetTaskTags_OnRunning_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Running); + var queue = CreateQueue(); + var sut = BuildSut(queue); + + await Assert.ThrowsAsync(() => + sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None)); + } }