Merge claudedo/b8004a65aba245bfbce192d10d90bb36
This commit is contained in:
@@ -190,7 +190,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
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]
|
||||
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
[ObservableProperty] private int _number;
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string _listName = "";
|
||||
[ObservableProperty] private bool _done;
|
||||
@@ -351,6 +352,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public void UpdateFromEntity(TaskEntity t)
|
||||
{
|
||||
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
|
||||
Number = t.Number;
|
||||
Title = t.Title;
|
||||
ListName = t.List?.Name ?? "";
|
||||
Done = t.Status == TaskStatus.Done;
|
||||
|
||||
@@ -66,14 +66,18 @@
|
||||
<!-- Title + chip row + live tail -->
|
||||
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
|
||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0"
|
||||
Classes="task-title"
|
||||
Text="{Binding Title}" FontSize="{StaticResource FontSizeTaskTitle}"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextWrapping="Wrap"
|
||||
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
|
||||
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
|
||||
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
|
||||
<Grid Grid.Column="0" ColumnDefinitions="Auto,*" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding Number, StringFormat='#{0}'}"
|
||||
VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Grid.Column="1"
|
||||
Classes="task-title"
|
||||
Text="{Binding Title}" FontSize="{StaticResource FontSizeTaskTitle}"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
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 -->
|
||||
<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}",
|
||||
candidate.Id, wt.BranchName, targetBranch, detail);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -429,7 +429,7 @@ public sealed class TaskMergeService
|
||||
if (verifyFailure is not null)
|
||||
{
|
||||
_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;
|
||||
}
|
||||
|
||||
@@ -438,7 +438,7 @@ public sealed class TaskMergeService
|
||||
_logger.LogInformation(
|
||||
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
|
||||
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);
|
||||
}
|
||||
@@ -523,7 +523,7 @@ public sealed class TaskMergeService
|
||||
if (verifyFailure is not null)
|
||||
{
|
||||
_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;
|
||||
}
|
||||
|
||||
@@ -625,7 +625,7 @@ public sealed class TaskMergeService
|
||||
_logger.LogInformation(
|
||||
"Reverted merge of task {TaskId} (merge commit {MergeSha}) via revert commit {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);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ public sealed class TaskResetService
|
||||
if (wt is not null && wt.State == WorktreeState.Active && list.WorkingDir is not null)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,6 @@ public sealed class TaskResetService
|
||||
await _broadcaster.WorktreeUpdated(taskId);
|
||||
|
||||
_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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ public sealed class TaskRunner
|
||||
list = await listRepo.GetByIdAsync(task.ListId, ct);
|
||||
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;
|
||||
}
|
||||
listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
|
||||
@@ -117,7 +117,7 @@ public sealed class TaskRunner
|
||||
var prep = await PrepareRunDirectoryAsync(task, list, ct);
|
||||
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;
|
||||
}
|
||||
var wtCtx = prep.WtCtx;
|
||||
@@ -135,7 +135,7 @@ public sealed class TaskRunner
|
||||
attachmentPaths);
|
||||
|
||||
// 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)
|
||||
{
|
||||
@@ -150,7 +150,7 @@ public sealed class TaskRunner
|
||||
var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
|
||||
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)
|
||||
{
|
||||
@@ -158,13 +158,13 @@ public sealed class TaskRunner
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -174,12 +174,12 @@ public sealed class TaskRunner
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_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
|
||||
{
|
||||
@@ -264,7 +264,7 @@ public sealed class TaskRunner
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -272,7 +272,7 @@ public sealed class TaskRunner
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -281,12 +281,12 @@ public sealed class TaskRunner
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_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
|
||||
{
|
||||
@@ -329,7 +329,7 @@ public sealed class TaskRunner
|
||||
try
|
||||
{
|
||||
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
|
||||
// as having no worktree until some unrelated event happens to refresh it.
|
||||
await _broadcaster.WorktreeUpdated(task.Id);
|
||||
@@ -348,7 +348,7 @@ public sealed class TaskRunner
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var runId = Guid.NewGuid().ToString();
|
||||
@@ -383,7 +383,7 @@ public sealed class TaskRunner
|
||||
|
||||
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(
|
||||
arguments,
|
||||
prompt,
|
||||
@@ -486,7 +486,7 @@ public sealed class TaskRunner
|
||||
committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -505,7 +505,7 @@ public sealed class TaskRunner
|
||||
if (wtCtx is not null && !committed && deniedWrites.Count > 0)
|
||||
{
|
||||
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 " +
|
||||
"was changed. Check the run's permission mode (get_effective_run_config).",
|
||||
result.TurnCount);
|
||||
@@ -542,20 +542,20 @@ public sealed class TaskRunner
|
||||
foreach (var child in pendingChildren)
|
||||
await _state.EnqueueAsync(child.Id, CancellationToken.None);
|
||||
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);
|
||||
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_children", finishedAt);
|
||||
}
|
||||
else if (isStandalone)
|
||||
{
|
||||
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);
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
}
|
||||
_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(
|
||||
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")
|
||||
{
|
||||
// Terminal write for a failed task: never cancel (the status must always
|
||||
@@ -573,7 +573,7 @@ public sealed class TaskRunner
|
||||
var finishedAt = DateTime.UtcNow;
|
||||
await _state.FailAsync(taskId, finishedAt, error, CancellationToken.None,
|
||||
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);
|
||||
_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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public async Task ResetAsync_RunningTask_Throws_AndDoesNotMutate()
|
||||
{
|
||||
|
||||
@@ -22,6 +22,24 @@ public class TaskRowViewModelTests
|
||||
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]
|
||||
public void IsDropTarget_Follows_Either_DropHint_And_Raises_PropertyChanged()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user