Merge claudedo/b8004a65aba245bfbce192d10d90bb36

This commit is contained in:
mika kuns
2026-08-11 13:54:17 +02:00
11 changed files with 361 additions and 38 deletions
@@ -190,7 +190,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public string SessionLabel => "claude-session"; public string SessionLabel => "claude-session";
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : ""; public string TaskIdBadge => Task != null ? $"#{Task.Number}" : "";
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))] [NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
@@ -8,6 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class TaskRowViewModel : ViewModelBase public sealed partial class TaskRowViewModel : ViewModelBase
{ {
public required string Id { get; init; } public required string Id { get; init; }
[ObservableProperty] private int _number;
[ObservableProperty] private string _title = ""; [ObservableProperty] private string _title = "";
[ObservableProperty] private string _listName = ""; [ObservableProperty] private string _listName = "";
[ObservableProperty] private bool _done; [ObservableProperty] private bool _done;
@@ -351,6 +352,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public void UpdateFromEntity(TaskEntity t) public void UpdateFromEntity(TaskEntity t)
{ {
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat); var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
Number = t.Number;
Title = t.Title; Title = t.Title;
ListName = t.List?.Name ?? ""; ListName = t.List?.Name ?? "";
Done = t.Status == TaskStatus.Done; Done = t.Status == TaskStatus.Done;
@@ -66,14 +66,18 @@
<!-- Title + chip row + live tail --> <!-- Title + chip row + live tail -->
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center"> <StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center"> <Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" <Grid Grid.Column="0" ColumnDefinitions="Auto,*" VerticalAlignment="Center">
Classes="task-title" <TextBlock Grid.Column="0" Classes="meta" Text="{Binding Number, StringFormat='#{0}'}"
Text="{Binding Title}" FontSize="{StaticResource FontSizeTaskTitle}" VerticalAlignment="Center" Margin="0,0,6,0"/>
Foreground="{DynamicResource TextBrush}" <TextBlock Grid.Column="1"
TextWrapping="Wrap" Classes="task-title"
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}" Text="{Binding Title}" FontSize="{StaticResource FontSizeTaskTitle}"
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}" Foreground="{DynamicResource TextBrush}"
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/> TextWrapping="Wrap"
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
</Grid>
<!-- Badges: DRAFT and planning session --> <!-- Badges: DRAFT and planning session -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
@@ -315,7 +315,7 @@ public sealed class TaskMergeService
"Auto-rebase of task {TaskId} branch {Branch} onto {Target} failed, left as-is: {Detail}", "Auto-rebase of task {TaskId} branch {Branch} onto {Target} failed, left as-is: {Detail}",
candidate.Id, wt.BranchName, targetBranch, detail); candidate.Id, wt.BranchName, targetBranch, detail);
await _broadcaster.WorkerLog( await _broadcaster.WorkerLog(
$"Auto-rebase failed for \"{candidate.Title}\" onto {targetBranch} — left as-is ({detail})", $"Auto-rebase failed for #{candidate.Number} \"{candidate.Title}\" onto {targetBranch} — left as-is ({detail})",
WorkerLogLevel.Warn, DateTime.UtcNow); WorkerLogLevel.Warn, DateTime.UtcNow);
return; return;
} }
@@ -429,7 +429,7 @@ public sealed class TaskMergeService
if (verifyFailure is not null) if (verifyFailure is not null)
{ {
_logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage); _logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
await _broadcaster.WorkerLog($"Verify failed for \"{task.Title}\" after merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow); await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
return verifyFailure; return verifyFailure;
} }
@@ -438,7 +438,7 @@ public sealed class TaskMergeService
_logger.LogInformation( _logger.LogInformation(
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})", "Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
taskId, wt.BranchName, targetBranch, removeWorktree); taskId, wt.BranchName, targetBranch, removeWorktree);
await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow); await _broadcaster.WorkerLog($"Merged #{task.Number} \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning); return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
} }
@@ -523,7 +523,7 @@ public sealed class TaskMergeService
if (verifyFailure is not null) if (verifyFailure is not null)
{ {
_logger.LogWarning("Verify command failed after continuing merge of task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage); _logger.LogWarning("Verify command failed after continuing merge of task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
await _broadcaster.WorkerLog($"Verify failed for \"{task.Title}\" after merge", WorkerLogLevel.Warn, DateTime.UtcNow); await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after merge", WorkerLogLevel.Warn, DateTime.UtcNow);
return verifyFailure; return verifyFailure;
} }
@@ -625,7 +625,7 @@ public sealed class TaskMergeService
_logger.LogInformation( _logger.LogInformation(
"Reverted merge of task {TaskId} (merge commit {MergeSha}) via revert commit {RevertSha}", "Reverted merge of task {TaskId} (merge commit {MergeSha}) via revert commit {RevertSha}",
taskId, wt.MergeCommit, revertSha); taskId, wt.MergeCommit, revertSha);
await _broadcaster.WorkerLog($"Reverted merge of \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow); await _broadcaster.WorkerLog($"Reverted merge of #{task.Number} \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
return new RevertResult(StatusReverted, revertSha, Array.Empty<string>(), null); return new RevertResult(StatusReverted, revertSha, Array.Empty<string>(), null);
} }
@@ -55,7 +55,7 @@ public sealed class TaskResetService
if (wt is not null && wt.State == WorktreeState.Active && list.WorkingDir is not null) if (wt is not null && wt.State == WorktreeState.Active && list.WorkingDir is not null)
{ {
await _wtManager.DiscardAsync(wt, list.WorkingDir, ct); await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);
await _broadcaster.WorkerLog($"Discarded worktree for \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow); await _broadcaster.WorkerLog($"Discarded worktree for #{task.Number} \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
worktreeChanged = true; worktreeChanged = true;
} }
@@ -66,6 +66,6 @@ public sealed class TaskResetService
await _broadcaster.WorktreeUpdated(taskId); await _broadcaster.WorktreeUpdated(taskId);
_logger.LogInformation("Reset task {TaskId} to Idle (worktree discarded: {Discarded})", taskId, worktreeChanged); _logger.LogInformation("Reset task {TaskId} to Idle (worktree discarded: {Discarded})", taskId, worktreeChanged);
await _broadcaster.WorkerLog($"Reset \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow); await _broadcaster.WorkerLog($"Reset #{task.Number} \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
} }
} }
+22 -22
View File
@@ -73,7 +73,7 @@ public sealed class TaskRunner
list = await listRepo.GetByIdAsync(task.ListId, ct); list = await listRepo.GetByIdAsync(task.ListId, ct);
if (list is null) if (list is null)
{ {
await MarkFailed(task.Id, task.Title, slot, "List not found."); await MarkFailed(task.Id, task.Number, task.Title, slot, "List not found.");
return; return;
} }
listConfig = await listRepo.GetConfigAsync(task.ListId, ct); listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
@@ -117,7 +117,7 @@ public sealed class TaskRunner
var prep = await PrepareRunDirectoryAsync(task, list, ct); var prep = await PrepareRunDirectoryAsync(task, list, ct);
if (prep.FailureReason is not null) if (prep.FailureReason is not null)
{ {
await MarkFailed(task.Id, task.Title, slot, prep.FailureReason); await MarkFailed(task.Id, task.Number, task.Title, slot, prep.FailureReason);
return; return;
} }
var wtCtx = prep.WtCtx; var wtCtx = prep.WtCtx;
@@ -135,7 +135,7 @@ public sealed class TaskRunner
attachmentPaths); attachmentPaths);
// Run 1. // Run 1.
var result = await RunOnceAsync(task.Id, task.Title, slot, runDir, resolvedConfig, 1, false, prompt, ct); var result = await RunOnceAsync(task.Id, task.Number, task.Title, slot, runDir, resolvedConfig, 1, false, prompt, ct);
if (result.IsSuccess) if (result.IsSuccess)
{ {
@@ -150,7 +150,7 @@ public sealed class TaskRunner
var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId }; var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
var retryPrompt = BuildRetryPrompt(result.ErrorMarkdown); var retryPrompt = BuildRetryPrompt(result.ErrorMarkdown);
var retryResult = await RunOnceAsync(task.Id, task.Title, slot, runDir, retryConfig, 2, true, retryPrompt, ct); var retryResult = await RunOnceAsync(task.Id, task.Number, task.Title, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
if (retryResult.IsSuccess) if (retryResult.IsSuccess)
{ {
@@ -158,13 +158,13 @@ public sealed class TaskRunner
} }
else else
{ {
await MarkFailed(task.Id, task.Title, slot, retryResult.ErrorMarkdown, retryResult.TurnCount, await MarkFailed(task.Id, task.Number, task.Title, slot, retryResult.ErrorMarkdown, retryResult.TurnCount,
retryConfig.MaxTurns, ClassifyFailureReason(retryResult.TerminalReason)); retryConfig.MaxTurns, ClassifyFailureReason(retryResult.TerminalReason));
} }
} }
else else
{ {
await MarkFailed(task.Id, task.Title, slot, result.ErrorMarkdown, result.TurnCount, await MarkFailed(task.Id, task.Number, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason)); resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason));
} }
} }
@@ -174,12 +174,12 @@ public sealed class TaskRunner
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
_logger.LogInformation("Task {TaskId} was cancelled", task.Id); _logger.LogInformation("Task {TaskId} was cancelled", task.Id);
await MarkFailed(task.Id, task.Title, slot, "Task cancelled.", failureReason: "cancelled"); await MarkFailed(task.Id, task.Number, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id); _logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id);
await MarkFailed(task.Id, task.Title, slot, $"Unhandled error: {ex.Message}"); await MarkFailed(task.Id, task.Number, task.Title, slot, $"Unhandled error: {ex.Message}");
} }
finally finally
{ {
@@ -264,7 +264,7 @@ public sealed class TaskRunner
try try
{ {
var nextRunNumber = lastRun.RunNumber + 1; var nextRunNumber = lastRun.RunNumber + 1;
var result = await RunOnceAsync(taskId, task.Title, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct); var result = await RunOnceAsync(taskId, task.Number, task.Title, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
if (result.IsSuccess) if (result.IsSuccess)
{ {
@@ -272,7 +272,7 @@ public sealed class TaskRunner
} }
else else
{ {
await MarkFailed(taskId, task.Title, slot, result.ErrorMarkdown, result.TurnCount, await MarkFailed(taskId, task.Number, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason)); resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason));
} }
@@ -281,12 +281,12 @@ public sealed class TaskRunner
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
_logger.LogInformation("Task {TaskId} was cancelled during continue", taskId); _logger.LogInformation("Task {TaskId} was cancelled during continue", taskId);
await MarkFailed(taskId, task.Title, slot, "Task cancelled.", failureReason: "cancelled"); await MarkFailed(taskId, task.Number, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Unhandled exception continuing task {TaskId}", taskId); _logger.LogError(ex, "Unhandled exception continuing task {TaskId}", taskId);
await MarkFailed(taskId, task.Title, slot, $"Unhandled error: {ex.Message}"); await MarkFailed(taskId, task.Number, task.Title, slot, $"Unhandled error: {ex.Message}");
} }
finally finally
{ {
@@ -329,7 +329,7 @@ public sealed class TaskRunner
try try
{ {
var wtCtx = await _wtManager.CreateAsync(task, list, ct); var wtCtx = await _wtManager.CreateAsync(task, list, ct);
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow); await _broadcaster.WorkerLog($"Created worktree for #{task.Number} \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
// The worktrees row was just inserted; without this the UI keeps showing the task // The worktrees row was just inserted; without this the UI keeps showing the task
// as having no worktree until some unrelated event happens to refresh it. // as having no worktree until some unrelated event happens to refresh it.
await _broadcaster.WorktreeUpdated(task.Id); await _broadcaster.WorktreeUpdated(task.Id);
@@ -348,7 +348,7 @@ public sealed class TaskRunner
} }
private async Task<RunResult> RunOnceAsync( private async Task<RunResult> RunOnceAsync(
string taskId, string taskTitle, string slot, string runDir, ClaudeRunConfig config, string taskId, int taskNumber, string taskTitle, string slot, string runDir, ClaudeRunConfig config,
int runNumber, bool isRetry, string prompt, CancellationToken ct) int runNumber, bool isRetry, string prompt, CancellationToken ct)
{ {
var runId = Guid.NewGuid().ToString(); var runId = Guid.NewGuid().ToString();
@@ -383,7 +383,7 @@ public sealed class TaskRunner
try try
{ {
await _broadcaster.WorkerLog($"Started Claude for \"{taskTitle}\"", WorkerLogLevel.Info, DateTime.UtcNow); await _broadcaster.WorkerLog($"Started Claude for #{taskNumber} \"{taskTitle}\"", WorkerLogLevel.Info, DateTime.UtcNow);
var result = await _claude.RunAsync( var result = await _claude.RunAsync(
arguments, arguments,
prompt, prompt,
@@ -486,7 +486,7 @@ public sealed class TaskRunner
committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct); committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
if (committed) if (committed)
{ {
await _broadcaster.WorkerLog($"Committed changes in \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow); await _broadcaster.WorkerLog($"Committed changes in #{task.Number} \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
await _broadcaster.WorktreeUpdated(task.Id); await _broadcaster.WorktreeUpdated(task.Id);
} }
} }
@@ -505,7 +505,7 @@ public sealed class TaskRunner
if (wtCtx is not null && !committed && deniedWrites.Count > 0) if (wtCtx is not null && !committed && deniedWrites.Count > 0)
{ {
await MarkFailed( await MarkFailed(
task.Id, task.Title, slot, task.Id, task.Number, task.Title, slot,
$"All edits were blocked by permission denials ({string.Join(", ", deniedWrites)}) and nothing " + $"All edits were blocked by permission denials ({string.Join(", ", deniedWrites)}) and nothing " +
"was changed. Check the run's permission mode (get_effective_run_config).", "was changed. Check the run's permission mode (get_effective_run_config).",
result.TurnCount); result.TurnCount);
@@ -542,20 +542,20 @@ public sealed class TaskRunner
foreach (var child in pendingChildren) foreach (var child in pendingChildren)
await _state.EnqueueAsync(child.Id, CancellationToken.None); await _state.EnqueueAsync(child.Id, CancellationToken.None);
await _broadcaster.WorkerLog( await _broadcaster.WorkerLog(
$"Finished \"{task.Title}\" (waiting on {pendingChildren.Count} improvement(s))", $"Finished #{task.Number} \"{task.Title}\" (waiting on {pendingChildren.Count} improvement(s))",
WorkerLogLevel.Success, DateTime.UtcNow); WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_children", finishedAt); await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_children", finishedAt);
} }
else if (isStandalone) else if (isStandalone)
{ {
await _state.SubmitForReviewAsync(task.Id, finishedAt, reviewResult, CancellationToken.None); await _state.SubmitForReviewAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow); await _broadcaster.WorkerLog($"Finished #{task.Number} \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt); await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
} }
else else
{ {
await _state.CompleteAsync(task.Id, finishedAt, reviewResult, CancellationToken.None); await _state.CompleteAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow); await _broadcaster.WorkerLog($"Finished #{task.Number} \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt); await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
} }
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})", _logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
@@ -563,7 +563,7 @@ public sealed class TaskRunner
} }
private async Task MarkFailed( private async Task MarkFailed(
string taskId, string taskTitle, string slot, string? error, int turnCount = 0, string taskId, int taskNumber, string taskTitle, string slot, string? error, int turnCount = 0,
int? maxTurns = null, string failureReason = "error") int? maxTurns = null, string failureReason = "error")
{ {
// Terminal write for a failed task: never cancel (the status must always // Terminal write for a failed task: never cancel (the status must always
@@ -573,7 +573,7 @@ public sealed class TaskRunner
var finishedAt = DateTime.UtcNow; var finishedAt = DateTime.UtcNow;
await _state.FailAsync(taskId, finishedAt, error, CancellationToken.None, await _state.FailAsync(taskId, finishedAt, error, CancellationToken.None,
failureReason, turnCount > 0 ? turnCount : null, maxTurns); failureReason, turnCount > 0 ? turnCount : null, maxTurns);
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow); await _broadcaster.WorkerLog($"Finished #{taskNumber} \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt); await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, turnCount, error); _logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, turnCount, error);
} }
@@ -0,0 +1,81 @@
using ClaudeDo.Data;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.Tests.ViewModels;
// Slice 4 of the task-numbers feature: the detail-pane header badge shows "#<Number>" instead of
// the old "#T<guid-prefix>" handle, which nobody could correlate back to a task.
public class DetailsIslandTaskIdBadgeTests : IDisposable
{
private readonly string _dbPath;
public DetailsIslandTaskIdBadgeTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_badge_test_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
{
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
private sealed class DefaultStub : StubWorkerClient { }
private DetailsIslandViewModel NewVm()
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
[Fact]
public void TaskIdBadge_NoTaskBound_IsEmpty()
{
var vm = NewVm();
Assert.Equal("", vm.TaskIdBadge);
}
[Fact]
public void TaskIdBadge_BoundTask_IsHashNumber()
{
var vm = NewVm();
vm.Bind(new TaskRowViewModel { Id = Guid.NewGuid().ToString("N"), Number = 123 });
Assert.Equal("#123", vm.TaskIdBadge);
}
}
@@ -0,0 +1,85 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Xunit;
namespace ClaudeDo.Worker.Tests.Runner;
// Slice 4 of the task-numbers feature: the curated WorkerLog business events must say "#<Number>"
// so a user who knows a task by its number (not its GUID) can find it in the worker-log strip.
public sealed class WorkerLogTaskNumberTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly string _tempDir;
private readonly WorkerConfig _cfg;
public WorkerLogTaskNumberTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"cd_wlnum_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
_cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir };
}
public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } }
[Fact]
public async Task RunAsync_Success_BroadcastsWorkerLog_ContainingTaskNumber()
{
var dbFactory = _db.CreateFactory();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "solo", ListId = "l1", Title = "Solo", Number = 123,
Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
var fake = new FakeClaudeProcess((_, _, _, _, _) =>
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" }));
var hub = new CapturingHubContext();
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(hub), wt,
new ClaudeArgsBuilder(), _cfg, NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(),
new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
using (var ctx = _db.CreateContext())
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("solo"))!, "slot-1", default, alreadyClaimed: true);
Assert.Contains(hub.Proxy.Calls, c => c.Method == "WorkerLog"
&& c.Args[0] is string s && s.Contains("#123"));
}
[Fact]
public async Task RunAsync_Failure_BroadcastsWorkerLog_ContainingTaskNumber()
{
var dbFactory = _db.CreateFactory();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "fail1", ListId = "l1", Title = "Boom", Number = 456,
Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
var fake = new FakeClaudeProcess((_, _, _, _, _) =>
Task.FromResult(new RunResult { ExitCode = 1, ErrorMarkdown = "nope" }));
var hub = new CapturingHubContext();
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(hub), wt,
new ClaudeArgsBuilder(), _cfg, NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(),
new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
using (var ctx = _db.CreateContext())
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("fail1"))!, "slot-1", default, alreadyClaimed: true);
Assert.Contains(hub.Proxy.Calls, c => c.Method == "WorkerLog"
&& c.Args[0] is string s && s.Contains("(failed)") && s.Contains("#456"));
}
}
@@ -0,0 +1,84 @@
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Services;
// Slice 4 of the task-numbers feature: TaskMergeService's curated WorkerLog business events must
// say "#<Number>" alongside the quoted title.
public sealed class TaskMergeServiceTaskNumberTests : IDisposable
{
private readonly List<DbFixture> _dbs = new();
private readonly List<GitRepoFixture> _repos = new();
private readonly List<(string repoDir, string wtPath)> _wtCleanups = new();
private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; }
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
public void Dispose()
{
foreach (var (repoDir, wtPath) in _wtCleanups)
{
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
}
foreach (var d in _dbs) try { d.Dispose(); } catch { }
foreach (var r in _repos) try { r.Dispose(); } catch { }
}
[Fact]
public async Task MergeAsync_Success_BroadcastsWorkerLog_ContainingTaskNumber()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var list = new ListEntity
{
Id = Guid.NewGuid().ToString(), Name = "merge-num-test", WorkingDir = repo.RepoDir,
DefaultCommitType = "feat", CreatedAt = DateTime.UtcNow,
};
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "merge-task", Number = 789,
Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow,
};
using (var ctx = db.CreateContext())
{
// Bypass TaskRepository.AddAsync — it allocates its own Number via
// TaskNumberAllocator and would silently overwrite the one this test sets.
ctx.Lists.Add(list);
ctx.Tasks.Add(task);
await ctx.SaveChangesAsync();
}
var wtMgr = new WorktreeManager(new GitService(), db.CreateFactory(),
new WorkerConfig { WorktreeRootStrategy = "sibling" }, NullLogger<WorktreeManager>.Instance);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var hub = new CapturingHubContext();
var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
var svc = new TaskMergeService(
db.CreateFactory(), new GitService(), new HubBroadcaster(hub), state,
new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge task", ct: CancellationToken.None);
Assert.Equal("merged", result.Status);
Assert.Contains(hub.Proxy.Calls, c => c.Method == "WorkerLog"
&& c.Args[0] is string s && s.Contains("Merged") && s.Contains("#789"));
}
}
@@ -143,6 +143,55 @@ public class TaskResetServiceTests : IDisposable
Assert.Contains(proxy.Calls, i => i.Method == "WorktreeUpdated" && i.Args[0] is string s && s == task.Id); Assert.Contains(proxy.Calls, i => i.Method == "WorktreeUpdated" && i.Args[0] is string s && s == task.Id);
} }
[Fact]
public async Task ResetAsync_BroadcastsWorkerLog_ContainingTaskNumber()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var wtMgr = BuildWorktreeManager(db);
var list = new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = "reset-number-test",
WorkingDir = repo.RepoDir,
DefaultCommitType = "feat",
CreatedAt = DateTime.UtcNow,
};
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "numbered task",
Number = 321,
Status = TaskStatus.Failed,
StartedAt = DateTime.UtcNow.AddMinutes(-5),
FinishedAt = DateTime.UtcNow.AddMinutes(-1),
Result = "some error",
CreatedAt = DateTime.UtcNow,
};
using (var ctx = db.CreateContext())
{
// Bypass TaskRepository.AddAsync — it allocates its own Number via
// TaskNumberAllocator and would silently overwrite the one this test sets.
ctx.Lists.Add(list);
ctx.Tasks.Add(task);
await ctx.SaveChangesAsync();
}
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
var (svc, proxy) = BuildService(db, wtMgr);
await svc.ResetAsync(task.Id, CancellationToken.None);
Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog"
&& c.Args[0] is string s && s.Contains("#321"));
}
[Fact] [Fact]
public async Task ResetAsync_RunningTask_Throws_AndDoesNotMutate() public async Task ResetAsync_RunningTask_Throws_AndDoesNotMutate()
{ {
@@ -22,6 +22,24 @@ public class TaskRowViewModelTests
Assert.Equal(expected, vm.StatusChipClass); Assert.Equal(expected, vm.StatusChipClass);
} }
[Fact]
public void Number_SetFromEntity_SurvivesUpdateFromEntity()
{
var entity = new TaskEntity
{
Id = "t", ListId = "l1", Title = "Task", Number = 123, CreatedAt = DateTime.UtcNow,
};
var vm = TaskRowViewModel.FromEntity(entity);
Assert.Equal(123, vm.Number);
// Simulates the TaskUpdated refresh path: the entity is re-fetched and the same
// row is updated in place rather than replaced.
entity.Title = "Task (edited)";
vm.UpdateFromEntity(entity);
Assert.Equal(123, vm.Number);
}
[Fact] [Fact]
public void IsDropTarget_Follows_Either_DropHint_And_Raises_PropertyChanged() public void IsDropTarget_Follows_Either_DropHint_And_Raises_PropertyChanged()
{ {