feat: ship agent-first mission control v0.2.57
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s

This commit is contained in:
AzuTear
2026-07-31 22:39:47 +02:00
parent 3bc7622977
commit f5552218bc
535 changed files with 95242 additions and 8791 deletions
+111 -21
View File
@@ -43,7 +43,7 @@ public sealed class TaskService(
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
PublishBoardInvalidation(task.Id);
return task;
}
@@ -58,7 +58,7 @@ public sealed class TaskService(
task.State = TaskStateHelper.ToStateString(TaskState.Done);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
PublishBoardInvalidation(task.Id);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -73,7 +73,7 @@ public sealed class TaskService(
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
PublishBoardInvalidation(task.Id);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -118,7 +118,7 @@ public sealed class TaskService(
await taskRepo.UpdateAsync(task, ct);
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" aktualisiert: {changeSummary}", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
PublishBoardInvalidation(task.Id);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -132,8 +132,8 @@ public sealed class TaskService(
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted", TaskId = task.Id }, ct);
await taskRepo.DeleteAsync(task, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success);
PublishBoardInvalidation(task.Id);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
@@ -238,7 +238,7 @@ public sealed class TaskService(
ct);
}
await PublishBoardSnapshotAsync(ct);
PublishBoardInvalidation(task.Id);
return task;
}
@@ -279,7 +279,7 @@ public sealed class TaskService(
task.Id,
ct);
await PublishBoardSnapshotAsync(ct);
PublishBoardInvalidation(task.Id);
return task;
}
@@ -350,7 +350,7 @@ public sealed class TaskService(
ct);
}
await PublishBoardSnapshotAsync(ct);
PublishBoardInvalidation(task.Id);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -395,7 +395,7 @@ public sealed class TaskService(
task.State = "Done";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
PublishBoardInvalidation(task.Id);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -414,7 +414,7 @@ public sealed class TaskService(
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
PublishBoardInvalidation(task.Id);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -452,12 +452,88 @@ public sealed class TaskService(
return new BoardResponse(offen, inProgress, review, blocked, done);
}
private async Task PublishBoardSnapshotAsync(CancellationToken ct = default)
public async Task<TaskBoardPageDto> GetBoardPageAsync(
int doneLimit = 50,
string? doneCursor = null,
CancellationToken ct = default)
{
var board = await GetBoardAsync(ct);
liveUpdateService.Publish("tasks.board.snapshot", board, "board");
if (doneLimit is < 1 or > 100)
throw new ArgumentOutOfRangeException(nameof(doneLimit), "Done limit must be between 1 and 100.");
DateTimeOffset? doneBeforeUpdatedAt = null;
Guid? doneBeforeId = null;
if (doneCursor is not null)
{
if (!TaskBoardCursorCodec.TryDecode(doneCursor, out var cursor))
throw new InvalidTaskBoardCursorException();
doneBeforeUpdatedAt = cursor.UpdatedAt;
doneBeforeId = cursor.Id;
}
var page = await taskRepo.GetBoardPageAsync(
doneLimit,
doneBeforeUpdatedAt,
doneBeforeId,
ct);
var offen = new List<TaskBoardCardDto>();
var inProgress = new List<TaskBoardCardDto>();
var review = new List<TaskBoardCardDto>();
var blocked = new List<TaskBoardCardDto>();
foreach (var task in page.ActiveTasks)
{
switch (task.State.ToLowerInvariant())
{
case "in progress":
inProgress.Add(task);
break;
case "review":
review.Add(task);
break;
case "blocked":
blocked.Add(task);
break;
default:
offen.Add(task);
break;
}
}
var revision = page.Revision is null
? TaskBoardCursorCodec.Encode(DateTimeOffset.UnixEpoch, Guid.Empty)
: TaskBoardCursorCodec.Encode(page.Revision.UpdatedAt, page.Revision.Id);
var nextDoneCursor = page.HasMoreDone && page.DoneTasks.Count > 0
? TaskBoardCursorCodec.Encode(page.DoneTasks[^1].UpdatedAt, page.DoneTasks[^1].Id)
: null;
return new TaskBoardPageDto(
revision,
offen,
inProgress,
review,
blocked,
page.DoneTasks,
nextDoneCursor,
page.HasMoreDone);
}
public Task<TaskBoardCardDto?> GetBoardCardAsync(
Guid id,
CancellationToken ct = default)
=> taskRepo.GetBoardCardAsync(id, ct);
private void PublishBoardInvalidation(Guid taskId)
// The legacy dashboard SSE adapter replaces this content-minimized
// signal with a compatibility snapshot for its own subscribers.
// Mutations therefore never pay the old all-task/all-activity query.
=> liveUpdateService.Publish(
"tasks.board.snapshot",
new { taskId },
"board");
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
{
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
@@ -535,7 +611,8 @@ public sealed class TaskService(
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,
t.IsAgentTask, t.ExpectedFrom);
t.IsAgentTask, t.ExpectedFrom,
ProjectId: t.ProjectId);
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity, IReadOnlyList<WorkTask>? _allTasks = null)
{
@@ -553,7 +630,8 @@ public sealed class TaskService(
null,
0,
0,
t.ParentTaskId.HasValue || t.IsAgentTask);
t.ParentTaskId.HasValue || t.IsAgentTask,
t.ProjectId);
}
private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
@@ -582,11 +660,23 @@ public sealed class TaskService(
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null) return "nexus-system";
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
return agentHeader.Trim().ToLowerInvariant();
var user = httpContext.User;
if (user?.Identity?.IsAuthenticated != true)
return "";
var canUseAgentHint = user.IsInRole("Service") ||
user.IsInRole("owner") ||
user.IsInRole("admin");
if (canUseAgentHint)
{
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
return agentHeader.Trim().ToLowerInvariant();
}
if (user.IsInRole("owner") || user.IsInRole("admin"))
return "bao";
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return nameClaim?.ToLowerInvariant() ?? "";
}
@@ -608,7 +698,7 @@ public sealed class TaskService(
TaskId = task.Id
}, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, actor, ct);
await PublishBoardSnapshotAsync(ct);
PublishBoardInvalidation(task.Id);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}