feat: ship agent-first mission control v0.2.57
This commit is contained in:
+111
-21
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user