feat(mission-control): show running Worker operations in a Running now section
Registry of active verify/merge/rebase/worktree-creation operations in the Worker, GetActiveOperations snapshot + OperationStarted/OperationEnded hub events, and a Running now section in Mission Control with ticking elapsed time. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
488c28a9d5
commit
2dbec543df
@@ -37,6 +37,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public event Action<string>? RefineStartedEvent;
|
||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
public event Action<string, string, int, int>? OperationProgressEvent;
|
||||
public event Action<RunningOperationDto>? OperationStartedEvent;
|
||||
public event Action<string>? OperationEndedEvent;
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||
@@ -67,6 +69,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
|
||||
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
|
||||
public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total);
|
||||
public void RaiseOperationStarted(RunningOperationDto op) => OperationStartedEvent?.Invoke(op);
|
||||
public void RaiseOperationEnded(string id) => OperationEndedEvent?.Invoke(id);
|
||||
public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch);
|
||||
public void RaisePlanningMergeAborted(string planningTaskId, string? reason = null) => PlanningMergeAbortedEvent?.Invoke(planningTaskId, reason);
|
||||
public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId);
|
||||
@@ -156,6 +160,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public virtual Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||
public virtual Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
||||
public virtual Task<IReadOnlyList<RunningOperationDto>> GetActiveOperationsAsync() => Task.FromResult<IReadOnlyList<RunningOperationDto>>(Array.Empty<RunningOperationDto>());
|
||||
public virtual Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync() => Task.FromResult<IReadOnlyList<WorkerLogEntry>>(Array.Empty<WorkerLogEntry>());
|
||||
public virtual Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
|
||||
=> Task.FromResult<IReadOnlyList<SubtaskDiffDto>>(Array.Empty<SubtaskDiffDto>());
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class MissionControlRunningOperationsTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_mcops_{Guid.NewGuid():N}.db");
|
||||
|
||||
public MissionControlRunningOperationsTests()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
||||
try { File.Delete(_dbPath + suffix); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeDoDbContext NewContext() =>
|
||||
new(new DbContextOptionsBuilder<ClaudeDoDbContext>().UseSqlite($"Data Source={_dbPath}").Options);
|
||||
|
||||
private sealed class FakeWorker : StubWorkerClient
|
||||
{
|
||||
public IReadOnlyList<RunningOperationDto> Snapshot { get; set; } = Array.Empty<RunningOperationDto>();
|
||||
public override Task<IReadOnlyList<RunningOperationDto>> GetActiveOperationsAsync() => Task.FromResult(Snapshot);
|
||||
}
|
||||
|
||||
private static RunningOperationDto Op(string id, string kind, int? number, string title, string? listId = null) =>
|
||||
new(id, kind, number is null ? null : "t-" + id, number, listId, title, DateTime.UtcNow.AddSeconds(-5));
|
||||
|
||||
private MissionControlViewModel BuildVm(FakeWorker worker) =>
|
||||
new(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker));
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_LoadsRunningOperations_AndShowsActivity()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Snapshot = new[]
|
||||
{
|
||||
Op("a", OperationKinds.VerifyMerge, 12, "Fix login"),
|
||||
Op("b", OperationKinds.VerifyBatch, null, "My list", listId: "l1"),
|
||||
},
|
||||
};
|
||||
var vm = BuildVm(worker);
|
||||
|
||||
await vm.RefreshOperationsAsync();
|
||||
|
||||
Assert.Equal(new[] { "#12 Fix login", "My list" }, vm.RunningOperations.Select(r => r.Subject));
|
||||
Assert.True(vm.HasRunningOperations);
|
||||
Assert.True(vm.HasActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartedAndEndedEvents_UpdateTheList()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
var vm = BuildVm(worker);
|
||||
await vm.RefreshOperationsAsync();
|
||||
Assert.False(vm.HasRunningOperations);
|
||||
|
||||
worker.RaiseOperationStarted(Op("a", OperationKinds.Merging, 3, "Task"));
|
||||
worker.RaiseOperationStarted(Op("a", OperationKinds.Merging, 3, "Task")); // duplicate ignored
|
||||
worker.RaiseOperationStarted(Op("b", OperationKinds.Rebasing, 4, "Other"));
|
||||
Assert.Equal(2, vm.RunningOperations.Count);
|
||||
|
||||
worker.RaiseOperationEnded("a");
|
||||
Assert.Equal("b", Assert.Single(vm.RunningOperations).Id);
|
||||
|
||||
worker.RaiseOperationEnded("unknown");
|
||||
worker.RaiseOperationEnded("b");
|
||||
Assert.False(vm.HasRunningOperations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectionRestored_ReplacesListWithFreshSnapshot()
|
||||
{
|
||||
var worker = new FakeWorker { Snapshot = new[] { Op("a", OperationKinds.Merging, 1, "Old") } };
|
||||
var vm = BuildVm(worker);
|
||||
await vm.RefreshOperationsAsync();
|
||||
|
||||
worker.Snapshot = new[] { Op("c", OperationKinds.CreatingWorktree, 2, "New") };
|
||||
await vm.RefreshOperationsAsync();
|
||||
|
||||
Assert.Equal("c", Assert.Single(vm.RunningOperations).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickOperations_UpdatesElapsed()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
var vm = BuildVm(worker);
|
||||
var started = new DateTime(2026, 10, 1, 8, 0, 0, DateTimeKind.Utc);
|
||||
worker.RaiseOperationStarted(new RunningOperationDto("a", OperationKinds.VerifyMerge, "t", 1, null, "T", started));
|
||||
|
||||
vm.TickOperations(started.AddSeconds(75));
|
||||
|
||||
Assert.Equal("01:15", vm.RunningOperations.Single().Elapsed);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Tests.Services;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Lifecycle;
|
||||
|
||||
public class RunningOperationRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Begin_AddsEntry_DisposeRemovesIt_AndIsIdempotent()
|
||||
{
|
||||
var ops = new RunningOperationRegistry();
|
||||
|
||||
var scope = ops.Begin(OperationKinds.Merging, "title", taskId: "t1", taskNumber: 7);
|
||||
var op = Assert.Single(ops.Snapshot());
|
||||
Assert.Equal(OperationKinds.Merging, op.Kind);
|
||||
Assert.Equal("t1", op.TaskId);
|
||||
Assert.Equal(7, op.TaskNumber);
|
||||
|
||||
scope.Dispose();
|
||||
scope.Dispose();
|
||||
Assert.Empty(ops.Snapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Entry_IsRemovedWhenTheOperationThrows()
|
||||
{
|
||||
var ops = new RunningOperationRegistry();
|
||||
|
||||
Action act = () =>
|
||||
{
|
||||
using var _ = ops.Begin(OperationKinds.Rebasing, "title", taskId: "t1");
|
||||
Assert.Single(ops.Snapshot());
|
||||
throw new InvalidOperationException("boom");
|
||||
};
|
||||
Assert.Throws<InvalidOperationException>(act);
|
||||
|
||||
Assert.Empty(ops.Snapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginAndDispose_BroadcastStartedAndEnded()
|
||||
{
|
||||
var hub = new MergeRecordingHubContext();
|
||||
var ops = new RunningOperationRegistry(new HubBroadcaster(hub));
|
||||
|
||||
var scope = ops.Begin(OperationKinds.CreatingWorktree, "title", taskId: "t1");
|
||||
var id = ops.Snapshot().Single().Id;
|
||||
scope.Dispose();
|
||||
|
||||
Assert.Collection(hub.Proxy.Calls,
|
||||
c => { Assert.Equal("OperationStarted", c.Method); Assert.Equal(id, ((RunningOperationDto)c.Args[0]!).Id); },
|
||||
c => { Assert.Equal("OperationEnded", c.Method); Assert.Equal(id, c.Args[0]); });
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ public class TaskMergeServiceTests : IDisposable
|
||||
}
|
||||
|
||||
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(
|
||||
DbFixture db, IVerifyCommandRunner? verify = null)
|
||||
DbFixture db, IVerifyCommandRunner? verify = null, RunningOperationRegistry? operations = null)
|
||||
{
|
||||
var fakeHub = new MergeRecordingHubContext();
|
||||
var broadcaster = new HubBroadcaster(fakeHub);
|
||||
@@ -42,7 +42,8 @@ public class TaskMergeServiceTests : IDisposable
|
||||
broadcaster,
|
||||
state,
|
||||
verify ?? new VerifyCommandRunner(),
|
||||
NullLogger<TaskMergeService>.Instance);
|
||||
NullLogger<TaskMergeService>.Instance,
|
||||
operations: operations);
|
||||
return (svc, fakeHub.Proxy);
|
||||
}
|
||||
|
||||
@@ -1335,6 +1336,62 @@ public class TaskMergeServiceTests : IDisposable
|
||||
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunningOperations_VerifyMerge_PresentDuringRunAndRemovedAfterSuccess()
|
||||
{
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
||||
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
||||
var ops = new RunningOperationRegistry();
|
||||
IReadOnlyList<RunningOperationDto>? during = null;
|
||||
var fakeVerify = new FakeVerifyCommandRunner { OnRun = () => during = ops.Snapshot() };
|
||||
var (svc, _) = BuildService(db, fakeVerify, ops);
|
||||
|
||||
await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
|
||||
|
||||
var op = Assert.Single(during!);
|
||||
Assert.Equal(OperationKinds.VerifyMerge, op.Kind);
|
||||
Assert.Equal(task.Id, op.TaskId);
|
||||
Assert.Equal("merge-task", op.Title);
|
||||
Assert.Empty(ops.Snapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunningOperations_VerifyMerge_RemovedWhenRunnerThrows()
|
||||
{
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
||||
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
||||
var ops = new RunningOperationRegistry();
|
||||
var fakeVerify = new FakeVerifyCommandRunner { ThrowOnRun = new InvalidOperationException("boom") };
|
||||
var (svc, _) = BuildService(db, fakeVerify, ops);
|
||||
|
||||
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
|
||||
Assert.Empty(ops.Snapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunningOperations_VerifyBatch_PresentDuringRunAndRemovedAfter()
|
||||
{
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
||||
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
||||
var ops = new RunningOperationRegistry();
|
||||
IReadOnlyList<RunningOperationDto>? during = null;
|
||||
var fakeVerify = new FakeVerifyCommandRunner { OnRun = () => during = ops.Snapshot() };
|
||||
var (svc, _) = BuildService(db, fakeVerify, ops);
|
||||
|
||||
await svc.RunListVerifyAsync(list.Id, new[] { task.Id }, CancellationToken.None);
|
||||
|
||||
var op = Assert.Single(during!);
|
||||
Assert.Equal(OperationKinds.VerifyBatch, op.Kind);
|
||||
Assert.Equal(list.Id, op.ListId);
|
||||
Assert.Equal("merge-test", op.Title);
|
||||
Assert.Empty(ops.Snapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAndMergeAsync_NoWorktreeAndVerifyPasses_MarksDone()
|
||||
{
|
||||
@@ -2208,9 +2265,15 @@ internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner
|
||||
// report without the real verify command ever taking that long.
|
||||
public TimeSpan? Delay { get; set; }
|
||||
|
||||
// Invoked at call time -- lets a test observe state while the verify run is "in flight".
|
||||
public Action? OnRun { get; set; }
|
||||
public Exception? ThrowOnRun { get; set; }
|
||||
|
||||
public async Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
CallCount++;
|
||||
OnRun?.Invoke();
|
||||
if (ThrowOnRun is not null) throw ThrowOnRun;
|
||||
CapturedWorkingDir = workingDir;
|
||||
CapturedCommand = command;
|
||||
if (FileToCheck is not null)
|
||||
|
||||
Reference in New Issue
Block a user