diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 4f79363..f1687d9 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -130,6 +130,33 @@ public sealed class ExternalMcpService return ToDto(entity); } + [McpServerTool, Description("Update an existing task's title, description, commit type, and/or tags. Pass null to leave a field unchanged. Tags are replaced as a full set when non-null. Refuses if the task is currently Running.")] + public async Task UpdateTask( + string taskId, + string? title, + string? description, + string? commitType, + 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 update a running task. Cancel it first."); + + if (title is not null) task.Title = title; + if (description is not null) task.Description = description; + if (commitType is not null) task.CommitType = commitType; + await _tasks.UpdateAsync(task, cancellationToken); + + if (tags is not null) + await _tasks.SetTagsAsync(taskId, tags, cancellationToken); + + var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; + await _broadcaster.TaskUpdated(taskId); + return ToDto(reload); + } + [McpServerTool, Description("Update a task's status. Only 'Manual' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")] public async Task UpdateTaskStatus( string taskId, diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 9a5ed11..9a4b6d4 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -172,4 +172,57 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.Empty(await _tasks.GetTagsAsync(dto.Id)); } + + [Fact] + public async Task UpdateTask_PatchesNonNullFieldsOnly() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, "old title"); + var queue = CreateQueue(); + var sut = BuildSut(queue); + + var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None); + + Assert.Equal("new title", dto.Title); + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal("new title", loaded!.Title); + } + + [Fact] + public async Task UpdateTask_TagsReplaceFullSet() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + await _tasks.SetTagsAsync(task.Id, new[] { "agent" }); + var queue = CreateQueue(); + var sut = BuildSut(queue); + + await sut.UpdateTask(task.Id, null, null, null, new[] { "manual" }, CancellationToken.None); + + var tags = await _tasks.GetTagsAsync(task.Id); + Assert.Single(tags); + Assert.Equal("manual", tags[0].Name); + } + + [Fact] + public async Task UpdateTask_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.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task UpdateTask_NotFound_Throws() + { + var queue = CreateQueue(); + var sut = BuildSut(queue); + + await Assert.ThrowsAsync(() => + sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None)); + } }