feat: complete task board workflow gates
This commit is contained in:
@@ -9,12 +9,10 @@ public sealed class TaskService(
|
||||
ITaskRepository taskRepo,
|
||||
IActivityRepository activityRepo,
|
||||
INotificationService notificationService,
|
||||
IAgentService agentService,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILiveUpdateService liveUpdateService) : ITaskService
|
||||
{
|
||||
private static readonly HashSet<string> ValidAssignees =
|
||||
["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"];
|
||||
|
||||
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||
=> await taskRepo.GetAllAsync(ct);
|
||||
|
||||
@@ -90,12 +88,7 @@ public sealed class TaskService(
|
||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
task.State = canonical;
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}", TaskId = task.Id }, ct);
|
||||
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task {task.Title} moved to {canonical}", ct);
|
||||
}
|
||||
|
||||
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
|
||||
@@ -204,7 +197,7 @@ public sealed class TaskService(
|
||||
}
|
||||
|
||||
var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
|
||||
var normalizedAssignee = ValidateAssignedTo(assignedTo);
|
||||
var normalizedAssignee = await NormalizeActorAsync(assignedTo, ct);
|
||||
var isVisibleDelegation = parentTaskId.HasValue;
|
||||
|
||||
var task = new WorkTask
|
||||
@@ -250,14 +243,14 @@ public sealed class TaskService(
|
||||
|
||||
public async Task<WorkTask> CreateAgentTaskAsync(
|
||||
string title, string? detail, string? source, string? priority,
|
||||
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default)
|
||||
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = null, CancellationToken ct = default)
|
||||
{
|
||||
var normalizedExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
|
||||
var normalizedExpectedFrom = await NormalizeActorAsync(expectedFrom, ct);
|
||||
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
|
||||
|
||||
task.IsAgentTask = true;
|
||||
task.ExpectedFrom = normalizedExpectedFrom;
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||
task.State = ResolveInitialAgentTaskState(startsInProgress, initialState);
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
@@ -322,7 +315,7 @@ public sealed class TaskService(
|
||||
}
|
||||
if (assignedTo is not null)
|
||||
{
|
||||
var validated = ValidateAssignedTo(assignedTo);
|
||||
var validated = await NormalizeActorAsync(assignedTo, ct);
|
||||
if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}");
|
||||
@@ -373,12 +366,24 @@ public sealed class TaskService(
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
|
||||
task.State = canonical;
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}", TaskId = task.Id }, ct);
|
||||
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", null, ct);
|
||||
}
|
||||
|
||||
public async Task<TaskOperationResult> StartCoordinationAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
if (!string.Equals(task.State, "Backlog", StringComparison.OrdinalIgnoreCase))
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
|
||||
return await UpdateTaskStatusInternalAsync(
|
||||
task,
|
||||
canonical: TaskStateHelper.ToStateString(TaskState.InProgress),
|
||||
actor: "nexus-system",
|
||||
activityType: "delegation",
|
||||
activityMessage: $"Task \"{task.Title}\" → In progress (coordination started by child-task creation)",
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
|
||||
@@ -481,12 +486,7 @@ public sealed class TaskService(
|
||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
task.State = canonical;
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}", TaskId = task.Id }, ct);
|
||||
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
|
||||
}
|
||||
|
||||
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
|
||||
@@ -577,11 +577,25 @@ public sealed class TaskService(
|
||||
t.ParentTaskId.HasValue || t.IsAgentTask);
|
||||
}
|
||||
|
||||
private static string? ValidateAssignedTo(string? assignedTo)
|
||||
private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
|
||||
var lower = assignedTo.Trim().ToLowerInvariant();
|
||||
return ValidAssignees.Contains(lower) ? lower : null;
|
||||
var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
|
||||
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
|
||||
}
|
||||
|
||||
private static string ResolveInitialAgentTaskState(bool startsInProgress, string? initialState)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(initialState))
|
||||
{
|
||||
var canonical = TaskStateHelper.AllStates.FirstOrDefault(state =>
|
||||
state.Equals(initialState, StringComparison.OrdinalIgnoreCase));
|
||||
if (canonical is not null)
|
||||
return canonical;
|
||||
}
|
||||
|
||||
return startsInProgress
|
||||
? TaskStateHelper.ToStateString(TaskState.InProgress)
|
||||
: TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||
}
|
||||
|
||||
private string ResolveCaller()
|
||||
@@ -598,10 +612,29 @@ public sealed class TaskService(
|
||||
return nameClaim?.ToLowerInvariant() ?? "";
|
||||
}
|
||||
|
||||
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct)
|
||||
private async Task<TaskOperationResult> UpdateTaskStatusInternalAsync(
|
||||
WorkTask task,
|
||||
string canonical,
|
||||
string actor,
|
||||
string activityType,
|
||||
string? activityMessage,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var caller = ResolveCaller();
|
||||
task.State = canonical;
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = activityType,
|
||||
Message = activityMessage ?? $"Task \"{task.Title}\" → {canonical}",
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
await CreateStatusChangeNotificationsAsync(task, canonical, actor, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, string caller, CancellationToken ct)
|
||||
{
|
||||
if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await notificationService.CreateAsync(
|
||||
|
||||
Reference in New Issue
Block a user