diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs index 5a19c62..231b9be 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs @@ -60,27 +60,41 @@ public sealed class PlanningMcpService return list; } - [McpServerTool, Description("Update an existing draft child task. Only Draft tasks may be modified.")] + private static readonly TaskStatus[] EditableStatuses = + { TaskStatus.Draft, TaskStatus.Manual, TaskStatus.Queued, TaskStatus.Waiting }; + + [McpServerTool, Description("Update a child task in the active planning session. Can change title, description, tags (replaces the full set), commit type, and status. Status must be one of: Draft, Manual, Queued, Waiting.")] public async Task UpdateChildTask( string taskId, string? title, string? description, IReadOnlyList? tags, string? commitType, + string? status, CancellationToken cancellationToken) { var ctx = _contextAccessor.Current; + var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken) + ?? throw new InvalidOperationException("Planning parent task not found."); + if (parent.Status != TaskStatus.Planning) + throw new InvalidOperationException("Cannot modify tasks outside an active planning session."); + var child = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (child.ParentTaskId != ctx.ParentTaskId) throw new InvalidOperationException("Task is not a child of this planning session."); - if (child.Status != TaskStatus.Draft) - throw new InvalidOperationException("Cannot modify a finalized task."); - if (title is not null) child.Title = title; - if (description is not null) child.Description = description; - if (commitType is not null) child.CommitType = commitType; - await _tasks.UpdateAsync(child, cancellationToken); + TaskStatus? newStatus = null; + if (!string.IsNullOrEmpty(status)) + { + if (!Enum.TryParse(status, ignoreCase: true, out var parsed)) + throw new InvalidOperationException($"Unknown status '{status}'."); + if (!EditableStatuses.Contains(parsed)) + throw new InvalidOperationException($"Status '{parsed}' cannot be set via MCP. Allowed: Draft, Manual, Queued, Waiting."); + newStatus = parsed; + } + + await _tasks.UpdateChildAsync(taskId, title, description, commitType, tags, newStatus, cancellationToken); var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken); @@ -89,18 +103,21 @@ public sealed class PlanningMcpService return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList()); } - [McpServerTool, Description("Delete a draft child task. Only Draft tasks may be deleted.")] + [McpServerTool, Description("Delete a child task in the active planning session.")] public async Task DeleteChildTask( string taskId, CancellationToken cancellationToken) { var ctx = _contextAccessor.Current; + var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken) + ?? throw new InvalidOperationException("Planning parent task not found."); + if (parent.Status != TaskStatus.Planning) + throw new InvalidOperationException("Cannot delete tasks outside an active planning session."); + var child = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (child.ParentTaskId != ctx.ParentTaskId) throw new InvalidOperationException("Task is not a child of this planning session."); - if (child.Status != TaskStatus.Draft) - throw new InvalidOperationException("Cannot delete a finalized task."); await _tasks.DeleteAsync(taskId, cancellationToken); await BroadcastTaskUpdatedAsync(taskId, cancellationToken); diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs index 3d9d08f..78a8fe8 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs @@ -138,11 +138,11 @@ public sealed class PlanningMcpServiceTests : IDisposable var sut = BuildSut(parent.Id); await Assert.ThrowsAsync(() => - sut.UpdateChildTask(otherChild.Id, "new", null, null, null, CancellationToken.None)); + sut.UpdateChildTask(otherChild.Id, "new", null, null, null, null, CancellationToken.None)); } [Fact] - public async Task UpdateChildTask_NotDraft_Throws() + public async Task UpdateChildTask_AfterFinalize_Throws() { var parent = await SeedPlanningParentAsync(); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); @@ -150,7 +150,75 @@ public sealed class PlanningMcpServiceTests : IDisposable var sut = BuildSut(parent.Id); await Assert.ThrowsAsync(() => - sut.UpdateChildTask(c.Id, "new", null, null, null, CancellationToken.None)); + sut.UpdateChildTask(c.Id, "new", null, null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task UpdateChildTask_SetsTags() + { + var parent = await SeedPlanningParentAsync(); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + _ctx.ChangeTracker.Clear(); + + var sut = BuildSut(parent.Id); + var result = await sut.UpdateChildTask(c.Id, null, null, new[] { "agent", "custom-tag" }, null, null, CancellationToken.None); + + Assert.Contains("agent", result.Tags); + Assert.Contains("custom-tag", result.Tags); + Assert.Equal(2, result.Tags.Count); + } + + [Fact] + public async Task UpdateChildTask_ReplacesTagSet() + { + var parent = await SeedPlanningParentAsync(); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, new[] { "agent" }, null); + _ctx.ChangeTracker.Clear(); + + var sut = BuildSut(parent.Id); + var result = await sut.UpdateChildTask(c.Id, null, null, new[] { "manual" }, null, null, CancellationToken.None); + + Assert.Single(result.Tags); + Assert.Equal("manual", result.Tags[0]); + } + + [Fact] + public async Task UpdateChildTask_SetsStatus() + { + var parent = await SeedPlanningParentAsync(); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + _ctx.ChangeTracker.Clear(); + + var sut = BuildSut(parent.Id); + var result = await sut.UpdateChildTask(c.Id, null, null, null, null, "Queued", CancellationToken.None); + + Assert.Equal("Queued", result.Status); + var loaded = await _tasks.GetByIdAsync(c.Id); + Assert.Equal(TaskStatus.Queued, loaded!.Status); + } + + [Fact] + public async Task UpdateChildTask_DisallowedStatus_Throws() + { + var parent = await SeedPlanningParentAsync(); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + _ctx.ChangeTracker.Clear(); + + var sut = BuildSut(parent.Id); + await Assert.ThrowsAsync(() => + sut.UpdateChildTask(c.Id, null, null, null, null, "Running", CancellationToken.None)); + } + + [Fact] + public async Task UpdateChildTask_UnknownStatus_Throws() + { + var parent = await SeedPlanningParentAsync(); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + _ctx.ChangeTracker.Clear(); + + var sut = BuildSut(parent.Id); + await Assert.ThrowsAsync(() => + sut.UpdateChildTask(c.Id, null, null, null, null, "NotARealStatus", CancellationToken.None)); } [Fact] @@ -215,7 +283,7 @@ public sealed class PlanningMcpServiceTests : IDisposable _ctx.ChangeTracker.Clear(); var sut = BuildSut(parent.Id); - await sut.UpdateChildTask(c.Id, "new title", null, null, null, CancellationToken.None); + await sut.UpdateChildTask(c.Id, "new title", null, null, null, null, CancellationToken.None); var ids = TaskUpdatedIds(); Assert.Contains(c.Id, ids);