From 121e8cd476ff1243c4a5d190f46708738ad31437 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 29 Apr 2026 10:39:44 +0200 Subject: [PATCH] feat(worker): add hub methods to set task status and tags freely Adds ForceSetStatusAsync on ITaskStateService (no transition guards) plus SetTaskStatus / SetTaskTags / GetAllTags hub methods so the UI can edit a task's status and tags directly. PlanningHubTests ctor updated for the new ITaskStateService dependency. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 50 ++++++++++++++++++- .../State/ITaskStateService.cs | 2 + src/ClaudeDo.Worker/State/TaskStateService.cs | 18 +++++++ .../Hub/PlanningHubTests.cs | 2 +- 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index cac4756..3e60811 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -7,7 +7,9 @@ using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Queue; +using ClaudeDo.Worker.State; using ClaudeDo.Worker.Worktrees; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -55,6 +57,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub private readonly PlanningMergeOrchestrator _planningMergeOrchestrator; private readonly PlanningChainCoordinator _planningChain; private readonly IPrimeScheduleSignal _primeSignal; + private readonly ITaskStateService _state; public WorkerHub( QueueService queue, @@ -71,7 +74,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub PlanningAggregator planningAggregator, PlanningMergeOrchestrator planningMergeOrchestrator, PlanningChainCoordinator planningChain, - IPrimeScheduleSignal primeSignal) + IPrimeScheduleSignal primeSignal, + ITaskStateService state) { _queue = queue; _waker = waker; @@ -88,6 +92,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub _planningMergeOrchestrator = planningMergeOrchestrator; _planningChain = planningChain; _primeSignal = primeSignal; + _state = state; } public async Task QueuePlanningSubtasksAsync(string parentTaskId) @@ -318,6 +323,49 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath); } + public async Task SetTaskStatus(string taskId, string status) + { + if (!Enum.TryParse(status, ignoreCase: true, out var parsed)) + throw new HubException($"unknown status: {status}"); + var result = await _state.ForceSetStatusAsync(taskId, parsed, Context.ConnectionAborted); + if (!result.Ok) throw new HubException(result.Reason ?? "set status failed"); + } + + public async Task SetTaskTags(string taskId, string[] tagNames) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + var entity = await ctx.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId); + if (entity is null) throw new HubException("task not found"); + + var desired = (tagNames ?? Array.Empty()) + .Select(n => n?.Trim().ToLowerInvariant() ?? "") + .Where(n => n.Length > 0) + .ToHashSet(); + + foreach (var t in entity.Tags.Where(t => !desired.Contains(t.Name)).ToList()) + entity.Tags.Remove(t); + + var existingByName = await ctx.Tags + .Where(t => desired.Contains(t.Name)) + .ToListAsync(); + foreach (var name in desired) + { + if (entity.Tags.Any(t => t.Name == name)) continue; + var tag = existingByName.FirstOrDefault(t => t.Name == name) + ?? new TagEntity { Name = name }; + if (tag.Id == 0) ctx.Tags.Add(tag); + entity.Tags.Add(tag); + } + await ctx.SaveChangesAsync(); + await _broadcaster.TaskUpdated(taskId); + } + + public async Task> GetAllTags() + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + return await ctx.Tags.OrderBy(t => t.Name).Select(t => t.Name).ToListAsync(); + } + public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto) { using var ctx = _dbFactory.CreateDbContext(); diff --git a/src/ClaudeDo.Worker/State/ITaskStateService.cs b/src/ClaudeDo.Worker/State/ITaskStateService.cs index 9d22df9..7ba0e05 100644 --- a/src/ClaudeDo.Worker/State/ITaskStateService.cs +++ b/src/ClaudeDo.Worker/State/ITaskStateService.cs @@ -9,6 +9,8 @@ public interface ITaskStateService Task CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct); Task ResetToIdleAsync(string taskId, CancellationToken ct); + Task ForceSetStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status, CancellationToken ct); + Task StartPlanningAsync(string parentId, CancellationToken ct); Task FinalizePlanningAsync(string parentId, CancellationToken ct); diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index 99e6ba0..4b3f768 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -140,6 +140,24 @@ public sealed class TaskStateService : ITaskStateService return new TransitionResult(true, null); } + // Unconditional status write — bypasses transition rules. Used by the UI's + // "set status freely" affordance; intentionally no guards (caller may strand + // the runner if used while a task is executing). + public async Task ForceSetStatusAsync(string taskId, TaskStatus status, CancellationToken ct) + { + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var affected = await ctx.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, status), ct); + + if (affected == 0) + return new TransitionResult(false, "Task not found."); + + if (status == TaskStatus.Queued) _waker.Wake(); + await _broadcaster.TaskUpdated(taskId); + return new TransitionResult(true, null); + } + public async Task StartPlanningAsync(string parentId, CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs index 44a8fa8..8349030 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs @@ -55,7 +55,7 @@ public sealed class PlanningHubTests : IDisposable { var hub = new WorkerHub( null!, null!, null!, null!, null!, null!, null!, null!, null!, - _planning, _launcher, null!, null!, null!, null!); + _planning, _launcher, null!, null!, null!, null!, null!); hub.Clients = new FakeHubCallerClients(_proxy); hub.Context = new FakeHubCallerContext(); return hub;