feat: complete task board workflow gates
CI - Build & Test / Backend (.NET) (push) Failing after 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 20s
CI - Build & Test / Security Check (push) Successful in 3s

This commit is contained in:
2026-06-24 01:22:13 +02:00
parent 68b428e411
commit 95495a8332
19 changed files with 1064 additions and 144 deletions
+44
View File
@@ -0,0 +1,44 @@
namespace Nexus.Api.Services;
public static class AgentIdentityCatalog
{
public static readonly string[] DefaultConfiguredAgentIds =
[
"main",
"iris",
"product-owner",
"programmer",
"programmer-fast",
"reviewer",
"architekt",
"researcher",
"executor"
];
private static readonly string[] WorkflowActorIds =
[
"bao",
"nexus-system"
];
public static IReadOnlySet<string> BuildAllowedActorIds(IEnumerable<string> configuredAgentIds)
{
var ids = new HashSet<string>(WorkflowActorIds, StringComparer.OrdinalIgnoreCase);
foreach (var configuredAgentId in configuredAgentIds)
{
if (!string.IsNullOrWhiteSpace(configuredAgentId))
ids.Add(configuredAgentId.Trim().ToLowerInvariant());
}
return ids;
}
public static string? NormalizeActorId(string? actorId, IReadOnlySet<string> allowedActorIds)
{
if (string.IsNullOrWhiteSpace(actorId))
return null;
var normalized = actorId.Trim().ToLowerInvariant();
return allowedActorIds.Contains(normalized) ? normalized : null;
}
}
+84 -16
View File
@@ -20,7 +20,8 @@ public sealed record AgentConfig
public string? AgentDir { get; init; }
[JsonPropertyName("model")]
public string? Model { get; init; }
[JsonConverter(typeof(AgentModelConfigConverter))]
public AgentModelConfig? Model { get; init; }
[JsonPropertyName("identity")]
public AgentIdentityConfig? Identity { get; init; }
@@ -44,6 +45,60 @@ public sealed record AgentIdentityConfig
public string Theme { get; init; } = string.Empty;
}
public sealed record AgentModelConfig
{
[JsonPropertyName("primary")]
public string? Primary { get; init; }
}
public sealed class AgentModelConfigConverter : JsonConverter<AgentModelConfig>
{
public override AgentModelConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
if (reader.TokenType == JsonTokenType.String)
{
var primary = reader.GetString();
return string.IsNullOrWhiteSpace(primary) ? null : new AgentModelConfig { Primary = primary };
}
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("Agent model must be either a string or an object.");
using var document = JsonDocument.ParseValue(ref reader);
var root = document.RootElement;
string? primary = null;
foreach (var property in root.EnumerateObject())
{
if (!string.Equals(property.Name, "primary", StringComparison.OrdinalIgnoreCase))
continue;
primary = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Null => null,
_ => throw new JsonException("Agent model primary must be a string.")
};
break;
}
return new AgentModelConfig { Primary = primary };
}
public override void Write(Utf8JsonWriter writer, AgentModelConfig value, JsonSerializerOptions options)
{
writer.WriteStartObject();
if (!string.IsNullOrWhiteSpace(value.Primary))
writer.WriteString("primary", value.Primary);
else
writer.WriteNull("primary");
writer.WriteEndObject();
}
}
public sealed record AgentInfo(
string Id,
string Name,
@@ -94,7 +149,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
var agents = new List<AgentInfo>(configs.Count);
foreach (var config in configs)
{
var model = config.Model ?? "deepseek/deepseek-v4-flash";
var model = ResolveModel(config);
var role = DeriveRole(config.Id);
var description = config.Identity?.Theme ?? string.Empty;
@@ -141,7 +196,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
Id: config.Id,
Name: config.Identity?.Name ?? config.Name ?? config.Id,
Role: role,
Model: config.Model ?? "deepseek/deepseek-v4-flash",
Model: ResolveModel(config),
Status: runtimeStatus.Status,
LastSeen: now,
Workspace: config.Workspace,
@@ -159,36 +214,43 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
return configs
.Where(config => !string.IsNullOrWhiteSpace(config.Id))
.Select(config => config.Id.Trim().ToLowerInvariant())
.DefaultIfEmpty()
.Where(id => !string.IsNullOrWhiteSpace(id))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "Orchestrator",
"product-owner" => "Product Owner",
"programmer" => "Developer",
"programmer-fast" => "Developer",
"reviewer" => "Reviewer",
"architekt" => "Architect",
"main" => "Assistant",
_ => "Custom"
};
private static string ResolveModel(AgentConfig config)
=> config.Model?.Primary ?? "deepseek/deepseek-v4-flash";
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
{
var path = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json";
if (!File.Exists(path))
return Array.Empty<AgentConfig>();
return BuildFallbackConfigs();
var json = await File.ReadAllTextAsync(path, cancellationToken);
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true });
var root = document.RootElement;
if (!root.TryGetProperty("agents", out var agentsElement))
return Array.Empty<AgentConfig>();
return BuildFallbackConfigs();
if (!agentsElement.TryGetProperty("list", out var listElement))
return Array.Empty<AgentConfig>();
return BuildFallbackConfigs();
var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement)
? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions)
@@ -204,29 +266,35 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
// Inherit defaults for missing fields
if (string.IsNullOrWhiteSpace(config.Name))
config = config with { Name = config.Id };
if (string.IsNullOrWhiteSpace(config.Model) && defaults?.Model?.Primary is not null)
config = config with { Model = defaults.Model.Primary };
if (string.IsNullOrWhiteSpace(config.Model?.Primary) && defaults?.Model?.Primary is not null)
config = config with { Model = new AgentModelConfig { Primary = defaults.Model.Primary } };
if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null)
config = config with { Workspace = defaults.Workspace };
configs.Add(config);
}
return configs.AsReadOnly();
return configs.Count > 0 ? configs.AsReadOnly() : BuildFallbackConfigs();
}
private static IReadOnlyList<AgentConfig> BuildFallbackConfigs()
=> AgentIdentityCatalog.DefaultConfiguredAgentIds
.Select(id => new AgentConfig
{
Id = id,
Name = id,
Model = new AgentModelConfig { Primary = "deepseek/deepseek-v4-flash" }
})
.ToList()
.AsReadOnly();
private sealed record AgentDefaults
{
[JsonPropertyName("workspace")]
public string? Workspace { get; init; }
[JsonPropertyName("model")]
public AgentDefaultModel? Model { get; init; }
}
private sealed record AgentDefaultModel
{
[JsonPropertyName("primary")]
public string? Primary { get; init; }
[JsonConverter(typeof(AgentModelConfigConverter))]
public AgentModelConfig? Model { get; init; }
}
}
+1
View File
@@ -42,6 +42,7 @@ public interface ITaskBridgeService
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default);
/// <summary>
+2 -1
View File
@@ -23,9 +23,10 @@ public interface ITaskService
// Dashboard-facing task operations
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default);
Task<WorkTask> CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default);
Task<WorkTask> CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = null, CancellationToken ct = default);
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default);
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
Task<TaskOperationResult> StartCoordinationAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
@@ -0,0 +1,50 @@
using Microsoft.Extensions.Primitives;
namespace Nexus.Api.Services;
public static class RequestAuthorizationHelper
{
public sealed record AgentHeaderResolution(string? AgentId, bool HeaderProvided, bool IsRecognized);
public static bool IsAuthenticatedService(HttpContext httpContext, IConfiguration configuration) =>
httpContext.User.IsInRole("Service") || HasValidServiceKey(httpContext, configuration);
public static bool IsPrivilegedUser(HttpContext httpContext) =>
httpContext.User.Identity?.IsAuthenticated == true &&
(httpContext.User.IsInRole("owner") || httpContext.User.IsInRole("admin"));
public static async Task<string?> ResolveAllowedAgentHeaderAsync(
HttpContext httpContext,
IAgentService agentService,
CancellationToken ct)
=> (await ResolveAgentHeaderAsync(httpContext, agentService, ct)).AgentId;
public static async Task<AgentHeaderResolution> ResolveAgentHeaderAsync(
HttpContext httpContext,
IAgentService agentService,
CancellationToken ct)
{
var headerValue = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(headerValue))
return new AgentHeaderResolution(null, HeaderProvided: false, IsRecognized: false);
var allowed = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
var normalized = AgentIdentityCatalog.NormalizeActorId(headerValue, allowed);
return new AgentHeaderResolution(
normalized,
HeaderProvided: true,
IsRecognized: normalized is not null);
}
public static bool HasValidServiceKey(HttpContext httpContext, IConfiguration configuration)
{
var configuredApiKey = configuration["NexusApiKey"];
if (string.IsNullOrWhiteSpace(configuredApiKey))
return false;
if (!httpContext.Request.Headers.TryGetValue("X-Nexus-Api-Key", out StringValues providedKey))
return false;
return string.Equals(configuredApiKey, providedKey.FirstOrDefault(), StringComparison.Ordinal);
}
}
+34 -18
View File
@@ -15,6 +15,7 @@ namespace Nexus.Api.Services;
/// </summary>
public sealed class TaskBridgeService(
ITaskService taskService,
IAgentService agentService,
IActivityRepository activityRepo,
INotificationService notificationService,
ILiveUpdateService liveUpdateService) : ITaskBridgeService
@@ -37,12 +38,11 @@ public sealed class TaskBridgeService(
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
var normalizedSource = NormalizeSource(source);
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
var task = await taskService.CreateDashboardTaskAsync(
title.Trim(), detail?.Trim(), normalizedSource, priority, normalizedAssignee, parentTaskId: null, ct);
title.Trim(), detail?.Trim(), normalizedSource, priority, assignedTo, parentTaskId: null, ct);
var dto = MapToDto(task);
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
return Success(dto);
}
@@ -56,6 +56,7 @@ public sealed class TaskBridgeService(
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(title))
@@ -66,19 +67,23 @@ public sealed class TaskBridgeService(
if (parent is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found.");
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
var task = await taskService.CreateAgentTaskAsync(
title.Trim(), detail?.Trim(), NormalizeSource(source),
priority, normalizedAssignee, expectedFrom, parentTaskId, ct);
priority, assignedTo, expectedFrom, parentTaskId, startsInProgress, null, ct);
// If parent was in Backlog, move it to InProgress (coordination starts)
if (string.Equals(parent.State, "Backlog", StringComparison.OrdinalIgnoreCase))
{
await taskService.UpdateStatusAsync(parentTaskId, "In progress", ct);
var parentTransition = await taskService.StartCoordinationAsync(parentTaskId, ct);
if (parentTransition.Outcome != TaskOperationOutcome.Success)
{
return Error<DashboardTaskDto>(
TaskBridgeOutcome.InvalidState,
$"Parent task {parentTaskId} could not be moved to In progress for coordination.");
}
}
var dto = MapToDto(task);
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
return Success(dto);
}
@@ -107,7 +112,7 @@ public sealed class TaskBridgeService(
if (result.Outcome != TaskOperationOutcome.Success)
return Error<DashboardTaskDto>(TaskBridgeOutcome.InvalidState, "Status update rejected.");
var dto = MapToDto(result.Task!);
var dto = await taskService.GetDashboardTaskByIdAsync(result.Task!.Id, ct) ?? MapToDto(result.Task);
return Success(dto);
}
@@ -157,7 +162,10 @@ public sealed class TaskBridgeService(
if (task is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
var normalizedTarget = targetAgent.Trim().ToLowerInvariant();
var normalizedTarget = await NormalizeActorAsync(targetAgent, ct);
if (normalizedTarget is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, $"Unknown target agent '{targetAgent}'.");
var handoffNote = string.IsNullOrWhiteSpace(note)
? $"Handoff → {normalizedTarget}"
: $"Handoff → {normalizedTarget}: {note.Trim()}";
@@ -186,7 +194,7 @@ public sealed class TaskBridgeService(
task.Id,
ct);
var dto = MapToDto(task);
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
return Success(dto);
}
@@ -207,8 +215,11 @@ public sealed class TaskBridgeService(
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
Guid parentTaskId, CancellationToken ct = default)
{
var children = await taskService.GetChildTasksAsync(parentTaskId, ct);
return children.Select(MapToDto).ToList();
var board = await taskService.GetBoardAsync(ct);
return FlattenBoard(board)
.Where(task => task.ParentTaskId == parentTaskId)
.OrderByDescending(task => task.UpdatedAt)
.ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
@@ -233,14 +244,19 @@ public sealed class TaskBridgeService(
private static string NormalizeSource(string? source) =>
string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant();
private static string? NormalizeAssignedTo(string? assignedTo)
private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
var valid = new HashSet<string> { "bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor" };
var lower = assignedTo.Trim().ToLowerInvariant();
return valid.Contains(lower) ? lower : null;
var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
}
private static IEnumerable<DashboardTaskDto> FlattenBoard(BoardResponse board)
=> board.Offen
.Concat(board.InProgress)
.Concat(board.Review)
.Concat(board.Blocked)
.Concat(board.Done);
private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
+65 -32
View File
@@ -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(