feat(ui): warn when the running worker predates the selected repo's merged HEAD
Stamps ClaudeDo.Worker's build with its exact git SHA (SourceRevisionId -> InformationalVersion) and exposes it via a new GetWorkerBuildInfo hub call. For the currently selected list, the shell compares that SHA against the list's git HEAD (GitService.IsAncestorAsync) and shows a persistent footer banner -- never auto-clearing, never shown on an unrelated repo or when the ancestry can't be determined -- so "verified against a merge" claims aren't silently made against a stale process. No auto-restart; the banner just offers the existing RestartWorkerCommand.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
public class StaleWorkerBannerDecisionTests
|
||||
{
|
||||
private const string ShaA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private const string ShaB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
[Fact]
|
||||
public void True_when_build_is_a_strict_ancestor_of_head()
|
||||
{
|
||||
Assert.True(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaB, isAncestor: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void False_when_build_equals_head_even_if_ancestor_check_says_true()
|
||||
{
|
||||
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaA, isAncestor: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void False_when_ancestor_check_says_no()
|
||||
{
|
||||
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaB, isAncestor: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void False_when_ancestor_check_is_unknown()
|
||||
{
|
||||
// Unknown must never be treated as stale.
|
||||
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaB, isAncestor: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void False_when_build_sha_missing()
|
||||
{
|
||||
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(null, ShaB, isAncestor: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void False_when_head_sha_missing()
|
||||
{
|
||||
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, null, isAncestor: true));
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
|
||||
public string LastPrepLog = "";
|
||||
public virtual Task<string> GetLastPrepLogAsync() => Task.FromResult(LastPrepLog);
|
||||
public WorkerBuildInfoDto? WorkerBuildInfo;
|
||||
public virtual Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync() => Task.FromResult(WorkerBuildInfo);
|
||||
public virtual Task RefineTaskAsync(string taskId) => Task.CompletedTask;
|
||||
|
||||
public virtual Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync() => Task.FromResult<OnlineInboxStateDto?>(null);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Hub;
|
||||
|
||||
public sealed class WorkerBuildInfoHubTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData("1.2.3", null)]
|
||||
[InlineData("1.2.3+", null)]
|
||||
[InlineData("1.2.3+abc1234", "abc1234")]
|
||||
// MinVer's own pre-release metadata can already contain a '+' (e.g. "+devsha"); our
|
||||
// SourceRevisionId is appended last, so only the segment after the LAST '+' is authoritative.
|
||||
[InlineData("0.0.0-alpha.0.4+devsha+f5b1a2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9", "f5b1a2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9")]
|
||||
public void ParseBuildSha_ExtractsSegmentAfterLastPlus(string? informationalVersion, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, WorkerHub.ParseBuildSha(informationalVersion));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWorkerBuildInfo_ReturnsDto()
|
||||
{
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, null!, null!,
|
||||
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
|
||||
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
|
||||
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
|
||||
|
||||
// No assertion on the actual SHA value (depends on the build environment) — just that
|
||||
// the call doesn't throw and always returns a DTO, never null.
|
||||
var info = hub.GetWorkerBuildInfo();
|
||||
Assert.NotNull(info);
|
||||
}
|
||||
}
|
||||
@@ -317,4 +317,45 @@ public class GitServiceMergeTests : IDisposable
|
||||
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
||||
Assert.True(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAncestorAsync_EarlierCommit_ReturnsTrue()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var first = repo.BaseCommit;
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "second.txt"), "second\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: second commit");
|
||||
|
||||
var git = new GitService();
|
||||
var head = (await git.RevParseHeadAsync(repo.RepoDir)).Trim();
|
||||
|
||||
Assert.True(await git.IsAncestorAsync(repo.RepoDir, first, head));
|
||||
Assert.False(await git.IsAncestorAsync(repo.RepoDir, head, first));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAncestorAsync_SameCommit_ReturnsTrue()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var git = new GitService();
|
||||
var head = (await git.RevParseHeadAsync(repo.RepoDir)).Trim();
|
||||
|
||||
Assert.True(await git.IsAncestorAsync(repo.RepoDir, head, head));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAncestorAsync_UnknownCommit_ReturnsNull()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var git = new GitService();
|
||||
var head = (await git.RevParseHeadAsync(repo.RepoDir)).Trim();
|
||||
var bogus = new string('a', 40);
|
||||
|
||||
Assert.Null(await git.IsAncestorAsync(repo.RepoDir, bogus, head));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
public Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask;
|
||||
public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
|
||||
public Task<string> GetLastPrepLogAsync() => Task.FromResult(string.Empty);
|
||||
public Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync() => Task.FromResult<WorkerBuildInfoDto?>(null);
|
||||
public Task RefineTaskAsync(string taskId) => Task.CompletedTask;
|
||||
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync() => Task.FromResult<OnlineInboxStateDto?>(null);
|
||||
public Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
|
||||
|
||||
Reference in New Issue
Block a user