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) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-29 10:39:44 +02:00
parent cfbe2fd7e3
commit 121e8cd476
4 changed files with 70 additions and 2 deletions

View File

@@ -7,7 +7,9 @@ using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Worktrees; using ClaudeDo.Worker.Worktrees;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -55,6 +57,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly PlanningMergeOrchestrator _planningMergeOrchestrator; private readonly PlanningMergeOrchestrator _planningMergeOrchestrator;
private readonly PlanningChainCoordinator _planningChain; private readonly PlanningChainCoordinator _planningChain;
private readonly IPrimeScheduleSignal _primeSignal; private readonly IPrimeScheduleSignal _primeSignal;
private readonly ITaskStateService _state;
public WorkerHub( public WorkerHub(
QueueService queue, QueueService queue,
@@ -71,7 +74,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
PlanningAggregator planningAggregator, PlanningAggregator planningAggregator,
PlanningMergeOrchestrator planningMergeOrchestrator, PlanningMergeOrchestrator planningMergeOrchestrator,
PlanningChainCoordinator planningChain, PlanningChainCoordinator planningChain,
IPrimeScheduleSignal primeSignal) IPrimeScheduleSignal primeSignal,
ITaskStateService state)
{ {
_queue = queue; _queue = queue;
_waker = waker; _waker = waker;
@@ -88,6 +92,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_planningMergeOrchestrator = planningMergeOrchestrator; _planningMergeOrchestrator = planningMergeOrchestrator;
_planningChain = planningChain; _planningChain = planningChain;
_primeSignal = primeSignal; _primeSignal = primeSignal;
_state = state;
} }
public async Task QueuePlanningSubtasksAsync(string parentTaskId) 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); return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath);
} }
public async Task SetTaskStatus(string taskId, string status)
{
if (!Enum.TryParse<TaskStatus>(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<string>())
.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<List<string>> 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) public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)
{ {
using var ctx = _dbFactory.CreateDbContext(); using var ctx = _dbFactory.CreateDbContext();

View File

@@ -9,6 +9,8 @@ public interface ITaskStateService
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct); Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct); Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
Task<TransitionResult> ForceSetStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status, CancellationToken ct);
Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct); Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct);
Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct); Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct);

View File

@@ -140,6 +140,24 @@ public sealed class TaskStateService : ITaskStateService
return new TransitionResult(true, null); 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<TransitionResult> 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<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct) public async Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct)
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);

View File

@@ -55,7 +55,7 @@ public sealed class PlanningHubTests : IDisposable
{ {
var hub = new WorkerHub( var hub = new WorkerHub(
null!, null!, null!, null!, null!, null!, null!, null!, null!, 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.Clients = new FakeHubCallerClients(_proxy);
hub.Context = new FakeHubCallerContext(); hub.Context = new FakeHubCallerContext();
return hub; return hub;