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:
@@ -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<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)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
|
||||
@@ -9,6 +9,8 @@ public interface ITaskStateService
|
||||
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, 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> FinalizePlanningAsync(string parentId, CancellationToken ct);
|
||||
|
||||
|
||||
@@ -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<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)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
Reference in New Issue
Block a user