refactor: extract interfaces to Interfaces folders and consolidate filters

Move interface declarations into per-area Interfaces/ subfolders, merge the
small task-list filter classes into StatusFilter/SmartFlagFilter, and simplify
related services, converters and hub DTO handling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-05-30 15:41:10 +02:00
parent 77100b6b3b
commit 41da124a31
42 changed files with 306 additions and 532 deletions

View File

@@ -1,12 +0,0 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class ImportantFilter : ITaskListFilter
{
public string Id => "smart:important";
public bool Matches(TaskEntity t) => t.IsStarred;
public bool ShouldCount(TaskEntity t) => t.IsStarred && t.Status != TaskStatus.Done;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -1,12 +0,0 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class MyDayFilter : ITaskListFilter
{
public string Id => "smart:my-day";
public bool Matches(TaskEntity t) => t.IsMyDay;
public bool ShouldCount(TaskEntity t) => t.IsMyDay && t.Status != TaskStatus.Done;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -1,12 +0,0 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class PlannedFilter : ITaskListFilter
{
public string Id => "smart:planned";
public bool Matches(TaskEntity t) => t.ScheduledFor != null;
public bool ShouldCount(TaskEntity t) => t.ScheduledFor != null && t.Status != TaskStatus.Done;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -1,14 +0,0 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class QueuedFilter : ITaskListFilter
{
public string Id => "virtual:queued";
public bool Matches(TaskEntity t) => t.Status == TaskStatus.Queued;
public bool ShouldCount(TaskEntity t) => t.Status == TaskStatus.Queued;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Queued);
}

View File

@@ -1,14 +0,0 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class RunningFilter : ITaskListFilter
{
public string Id => "virtual:running";
public bool Matches(TaskEntity t) => t.Status == TaskStatus.Running;
public bool ShouldCount(TaskEntity t) => t.Status == TaskStatus.Running;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Running);
}

View File

@@ -0,0 +1,16 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Filter for a smart list keyed off a boolean/nullable task flag
/// (My Day, Important, Planned). Counts only non-done matches.
/// </summary>
public sealed class SmartFlagFilter(string id, Func<TaskEntity, bool> flag) : ITaskListFilter
{
public string Id => id;
public bool Matches(TaskEntity t) => flag(t);
public bool ShouldCount(TaskEntity t) => flag(t) && t.Status != TaskStatus.Done;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -0,0 +1,18 @@
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Virtual list filter matching tasks by a single status (Queued, Running).
/// Planning parents appear contextually when they host a matching child.
/// </summary>
public sealed class StatusFilter(string id, TaskStatus status) : ITaskListFilter
{
public string Id => id;
public bool Matches(TaskEntity t) => t.Status == status;
public bool ShouldCount(TaskEntity t) => t.Status == status;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
}

View File

@@ -1,4 +1,5 @@
using ClaudeDo.Data.Filtering.Filters; using ClaudeDo.Data.Filtering.Filters;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering; namespace ClaudeDo.Data.Filtering;
@@ -14,11 +15,11 @@ public sealed class TaskListFilterRegistry
private static readonly IReadOnlyDictionary<string, ITaskListFilter> BuiltIn = private static readonly IReadOnlyDictionary<string, ITaskListFilter> BuiltIn =
new Dictionary<string, ITaskListFilter>(StringComparer.Ordinal) new Dictionary<string, ITaskListFilter>(StringComparer.Ordinal)
{ {
["smart:my-day"] = new MyDayFilter(), ["smart:my-day"] = new SmartFlagFilter("smart:my-day", t => t.IsMyDay),
["smart:important"] = new ImportantFilter(), ["smart:important"] = new SmartFlagFilter("smart:important", t => t.IsStarred),
["smart:planned"] = new PlannedFilter(), ["smart:planned"] = new SmartFlagFilter("smart:planned", t => t.ScheduledFor != null),
["virtual:queued"] = new QueuedFilter(), ["virtual:queued"] = new StatusFilter("virtual:queued", TaskStatus.Queued),
["virtual:running"] = new RunningFilter(), ["virtual:running"] = new StatusFilter("virtual:running", TaskStatus.Running),
["virtual:review"] = new ReviewFilter(), ["virtual:review"] = new ReviewFilter(),
}; };

View File

@@ -23,7 +23,7 @@ public sealed class AppSettingsRepository
return row; return row;
} }
public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default) private async Task<AppSettingsEntity> GetOrCreateTrackedRowAsync(CancellationToken ct)
{ {
var row = await _context.AppSettings var row = await _context.AppSettings
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct); .FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
@@ -32,6 +32,12 @@ public sealed class AppSettingsRepository
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }; row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
_context.AppSettings.Add(row); _context.AppSettings.Add(row);
} }
return row;
}
public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default)
{
var row = await GetOrCreateTrackedRowAsync(ct);
row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty; row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty;
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel; row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
@@ -62,13 +68,7 @@ public sealed class AppSettingsRepository
public async Task SetRepoImportFoldersAsync(IEnumerable<string> folders, CancellationToken ct = default) public async Task SetRepoImportFoldersAsync(IEnumerable<string> folders, CancellationToken ct = default)
{ {
var list = folders.ToList(); var list = folders.ToList();
var row = await _context.AppSettings var row = await GetOrCreateTrackedRowAsync(ct);
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
if (row is null)
{
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
_context.AppSettings.Add(row);
}
row.RepoImportFolders = list.Count == 0 ? null : JsonSerializer.Serialize(list); row.RepoImportFolders = list.Count == 0 ? null : JsonSerializer.Serialize(list);
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);

View File

@@ -5,6 +5,23 @@ using ClaudeDo.Data;
namespace ClaudeDo.Installer.Core; namespace ClaudeDo.Installer.Core;
internal static class JsonConfigFile
{
public static T LoadOrDefault<T>(string fileName, JsonSerializerOptions readOpts) where T : new()
{
var path = Path.Combine(Paths.AppDataRoot(), fileName);
if (!File.Exists(path)) return new();
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), readOpts) ?? new();
}
public static void Save<T>(string fileName, T value, JsonSerializerOptions writeOpts)
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, fileName), JsonSerializer.Serialize(value, writeOpts));
}
}
/// <summary> /// <summary>
/// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape. /// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape.
/// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs. /// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs.
@@ -47,21 +64,9 @@ public sealed class InstallerWorkerConfig
}; };
public static InstallerWorkerConfig Load() public static InstallerWorkerConfig Load()
{ => JsonConfigFile.LoadOrDefault<InstallerWorkerConfig>("worker.config.json", ReadOpts);
var path = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
if (!File.Exists(path)) return new();
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallerWorkerConfig>(json, ReadOpts) ?? new();
}
public void Save() public void Save() => JsonConfigFile.Save("worker.config.json", this, WriteOpts);
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "worker.config.json");
var json = JsonSerializer.Serialize(this, WriteOpts);
File.WriteAllText(path, json);
}
} }
/// <summary> /// <summary>
@@ -85,25 +90,9 @@ public sealed class InstallerAppSettings
public static InstallerAppSettings Load() public static InstallerAppSettings Load()
{ {
var path = Path.Combine(Paths.AppDataRoot(), "ui.config.json"); try { return JsonConfigFile.LoadOrDefault<InstallerAppSettings>("ui.config.json", ReadOpts); }
if (!File.Exists(path)) return new(); catch { return new(); }
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallerAppSettings>(json, ReadOpts) ?? new();
}
catch
{
return new();
}
} }
public void Save() public void Save() => JsonConfigFile.Save("ui.config.json", this, WriteOpts);
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "ui.config.json");
var json = JsonSerializer.Serialize(this, WriteOpts);
File.WriteAllText(path, json);
}
} }

View File

@@ -7,17 +7,23 @@ namespace ClaudeDo.Ui.Converters;
public sealed class DiffLineKindToBrushConverter : IValueConverter public sealed class DiffLineKindToBrushConverter : IValueConverter
{ {
private static readonly ISolidColorBrush Added = new SolidColorBrush(Color.Parse("#66BB6A"));
private static readonly ISolidColorBrush Removed = new SolidColorBrush(Color.Parse("#EF5350"));
private static readonly ISolidColorBrush Hunk = new SolidColorBrush(Color.Parse("#42A5F5"));
private static readonly ISolidColorBrush Header = new SolidColorBrush(Color.Parse("#9E9E9E"));
private static readonly ISolidColorBrush Default = new SolidColorBrush(Color.Parse("#CFD8DC"));
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is WorktreeDiffLineKind kind value is WorktreeDiffLineKind kind
? kind switch ? kind switch
{ {
WorktreeDiffLineKind.Added => new SolidColorBrush(Color.Parse("#66BB6A")), WorktreeDiffLineKind.Added => Added,
WorktreeDiffLineKind.Removed => new SolidColorBrush(Color.Parse("#EF5350")), WorktreeDiffLineKind.Removed => Removed,
WorktreeDiffLineKind.Hunk => new SolidColorBrush(Color.Parse("#42A5F5")), WorktreeDiffLineKind.Hunk => Hunk,
WorktreeDiffLineKind.Header => new SolidColorBrush(Color.Parse("#9E9E9E")), WorktreeDiffLineKind.Header => Header,
_ => new SolidColorBrush(Color.Parse("#CFD8DC")), _ => Default,
} }
: new SolidColorBrush(Color.Parse("#CFD8DC")); : Default;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException(); => throw new NotSupportedException();

View File

@@ -7,17 +7,23 @@ namespace ClaudeDo.Ui.Converters;
public sealed class WorktreeStateColorConverter : IValueConverter public sealed class WorktreeStateColorConverter : IValueConverter
{ {
private static readonly ISolidColorBrush Active = new SolidColorBrush(Color.Parse("#42A5F5"));
private static readonly ISolidColorBrush Merged = new SolidColorBrush(Color.Parse("#66BB6A"));
private static readonly ISolidColorBrush Discarded = new SolidColorBrush(Color.Parse("#9E9E9E"));
private static readonly ISolidColorBrush Kept = new SolidColorBrush(Color.Parse("#FFA726"));
private static readonly ISolidColorBrush Default = new SolidColorBrush(Colors.Gray);
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is WorktreeState state value is WorktreeState state
? state switch ? state switch
{ {
WorktreeState.Active => new SolidColorBrush(Color.Parse("#42A5F5")), WorktreeState.Active => Active,
WorktreeState.Merged => new SolidColorBrush(Color.Parse("#66BB6A")), WorktreeState.Merged => Merged,
WorktreeState.Discarded => new SolidColorBrush(Color.Parse("#9E9E9E")), WorktreeState.Discarded => Discarded,
WorktreeState.Kept => new SolidColorBrush(Color.Parse("#FFA726")), WorktreeState.Kept => Kept,
_ => new SolidColorBrush(Colors.Gray), _ => Default,
} }
: new SolidColorBrush(Colors.Gray); : Default;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException(); => throw new NotSupportedException();

View File

@@ -0,0 +1,49 @@
namespace ClaudeDo.Ui.Services;
/// <summary>
/// Locates an executable inside a ClaudeDo install: walk up from the running
/// directory to the folder containing install.json, otherwise read the
/// uninstall registry key. Subclasses supply the subdirectory and exe name.
/// </summary>
public abstract class InstallArtifactLocator
{
private const string InstallJson = "install.json";
protected abstract string Subdir { get; }
protected abstract string ExeName { get; }
public string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory)
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
public string? FindByWalkingUp(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, InstallJson)))
{
var candidate = Path.Combine(dir.FullName, Subdir, ExeName);
return File.Exists(candidate) ? candidate : null;
}
dir = dir.Parent;
}
return null;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public string? FindByRegistry()
{
if (!OperatingSystem.IsWindows()) return null;
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
var location = key?.GetValue("InstallLocation") as string;
if (string.IsNullOrEmpty(location)) return null;
var candidate = Path.Combine(location, Subdir, ExeName);
return File.Exists(candidate) ? candidate : null;
}
catch { return null; }
}
}

View File

@@ -1,48 +1,7 @@
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
public sealed class InstallerLocator public sealed class InstallerLocator : InstallArtifactLocator
{ {
private const string InstallJson = "install.json"; protected override string Subdir => "uninstaller";
private const string InstallerExe = "ClaudeDo.Installer.exe"; protected override string ExeName => "ClaudeDo.Installer.exe";
private const string UninstallerSubdir = "uninstaller";
public string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory)
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
public string? FindByWalkingUp(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
var manifest = Path.Combine(dir.FullName, InstallJson);
if (File.Exists(manifest))
{
var candidate = Path.Combine(dir.FullName, UninstallerSubdir, InstallerExe);
return File.Exists(candidate) ? candidate : null;
}
dir = dir.Parent;
}
return null;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public string? FindByRegistry()
{
if (!OperatingSystem.IsWindows()) return null;
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
var location = key?.GetValue("InstallLocation") as string;
if (string.IsNullOrEmpty(location)) return null;
var candidate = Path.Combine(location, UninstallerSubdir, InstallerExe);
return File.Exists(candidate) ? candidate : null;
}
catch
{
return null;
}
}
} }

View File

@@ -0,0 +1,8 @@
namespace ClaudeDo.Ui.Services;
public interface IPrimeScheduleApi
{
Task<List<PrimeScheduleDto>> ListAsync();
Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto);
Task DeleteAsync(Guid id);
}

View File

@@ -226,6 +226,13 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
try { await _hub.StopAsync(); } catch { /* swallow */ } try { await _hub.StopAsync(); } catch { /* swallow */ }
} }
/// <summary>Invoke a hub method, returning default (null) when the worker is offline or errors.</summary>
private async Task<T?> TryInvokeAsync<T>(string method, params object?[] args)
{
try { return await _hub.InvokeCoreAsync<T>(method, args); }
catch { return default; }
}
public async Task RunNowAsync(string taskId) public async Task RunNowAsync(string taskId)
{ {
RunNowRequestedEvent?.Invoke(taskId); RunNowRequestedEvent?.Invoke(taskId);
@@ -248,17 +255,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage); "MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
} }
public async Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
{ => TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
try
{
return await _hub.InvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
}
catch
{
return null;
}
}
public async Task CancelTaskAsync(string taskId) public async Task CancelTaskAsync(string taskId)
{ {
@@ -271,34 +269,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
} }
public async Task<List<AgentInfo>> GetAgentsAsync() public async Task<List<AgentInfo>> GetAgentsAsync()
{ => await TryInvokeAsync<List<AgentInfo>>("GetAgents") ?? [];
try
{
var agents = await _hub.InvokeAsync<List<AgentInfo>>("GetAgents");
return agents ?? [];
}
catch
{
return [];
}
}
public async Task RefreshAgentsAsync() public async Task RefreshAgentsAsync()
{ {
await _hub.InvokeAsync("RefreshAgents"); await _hub.InvokeAsync("RefreshAgents");
} }
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync() public Task<SeedResultDto?> RestoreDefaultAgentsAsync()
{ => TryInvokeAsync<SeedResultDto>("RestoreDefaultAgents");
try
{
return await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
}
catch
{
return null;
}
}
private async Task SeedActiveTasksAsync() private async Task SeedActiveTasksAsync()
{ {
@@ -329,17 +308,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.DisposeAsync(); await _hub.DisposeAsync();
} }
public async Task<AppSettingsDto?> GetAppSettingsAsync() public Task<AppSettingsDto?> GetAppSettingsAsync()
{ => TryInvokeAsync<AppSettingsDto>("GetAppSettings");
try
{
return await _hub.InvokeAsync<AppSettingsDto>("GetAppSettings");
}
catch
{
return null;
}
}
public async Task UpdateAppSettingsAsync(AppSettingsDto dto) public async Task UpdateAppSettingsAsync(AppSettingsDto dto)
{ {
@@ -347,16 +317,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
} }
public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync() public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
{ => await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>();
try { return await _hub.InvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules"); }
catch { return new List<PrimeScheduleDto>(); }
}
public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto) public Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
{ => TryInvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto);
try { return await _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto); }
catch { return null; }
}
public async Task DeletePrimeScheduleAsync(Guid id) public async Task DeletePrimeScheduleAsync(Guid id)
{ {
@@ -374,17 +338,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("UpdateListConfig", dto); await _hub.InvokeAsync("UpdateListConfig", dto);
} }
public async Task<ListConfigDto?> GetListConfigAsync(string listId) public Task<ListConfigDto?> GetListConfigAsync(string listId)
{ => TryInvokeAsync<ListConfigDto>("GetListConfig", listId);
try
{
return await _hub.InvokeAsync<ListConfigDto?>("GetListConfig", listId);
}
catch
{
return null;
}
}
public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto)
{ {
@@ -396,42 +351,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
} }
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null) public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
{ => TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
try
{
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
}
catch
{
return null;
}
}
public async Task<WorktreeResetDto?> ResetAllWorktreesAsync() public Task<WorktreeResetDto?> ResetAllWorktreesAsync()
{ => TryInvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
try
{
return await _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
}
catch
{
return null;
}
}
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId) public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
{ => await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId)
try ?? new List<WorktreeOverviewDto>();
{
var rows = await _hub.InvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId);
return rows ?? new List<WorktreeOverviewDto>();
}
catch
{
return new List<WorktreeOverviewDto>();
}
}
public async Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState) public async Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState)
{ {
@@ -450,17 +378,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
} }
} }
public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId) public Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
{ => TryInvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
try
{
return await _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
}
catch
{
return null;
}
}
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default) public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct); => await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
@@ -481,29 +400,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
=> await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct); => await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
{ => await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? [];
try
{
var result = await _hub.InvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId);
return result ?? [];
}
catch
{
return [];
}
}
public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
{ => TryInvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
try
{
return await _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
}
catch
{
return null;
}
}
public async Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) public async Task MergeAllPlanningAsync(string planningTaskId, string targetBranch)
{ {

View File

@@ -1,43 +1,7 @@
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
public sealed class WorkerLocator public sealed class WorkerLocator : InstallArtifactLocator
{ {
private const string InstallJson = "install.json"; protected override string Subdir => "worker";
private const string WorkerExe = "ClaudeDo.Worker.exe"; protected override string ExeName => "ClaudeDo.Worker.exe";
private const string WorkerSubdir = "worker";
public string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory)
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
public string? FindByWalkingUp(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, InstallJson)))
{
var candidate = Path.Combine(dir.FullName, WorkerSubdir, WorkerExe);
return File.Exists(candidate) ? candidate : null;
}
dir = dir.Parent;
}
return null;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public string? FindByRegistry()
{
if (!OperatingSystem.IsWindows()) return null;
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
var location = key?.GetValue("InstallLocation") as string;
if (string.IsNullOrEmpty(location)) return null;
var candidate = Path.Combine(location, WorkerSubdir, WorkerExe);
return File.Exists(candidate) ? candidate : null;
}
catch { return null; }
}
} }

View File

@@ -1,12 +1,5 @@
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
public interface IPrimeScheduleApi
{
Task<List<PrimeScheduleDto>> ListAsync();
Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto);
Task DeleteAsync(Guid id);
}
public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi
{ {
private readonly WorkerClient _client; private readonly WorkerClient _client;

View File

@@ -36,9 +36,9 @@ public sealed class ConfigMcpTools
_ = await _lists.GetByIdAsync(listId, cancellationToken) _ = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found."); ?? throw new InvalidOperationException($"List {listId} not found.");
var m = Nullify(model); var m = model.NullIfBlank();
var sp = Nullify(systemPrompt); var sp = systemPrompt.NullIfBlank();
var ap = Nullify(agentPath); var ap = agentPath.NullIfBlank();
if (m is null && sp is null && ap is null) if (m is null && sp is null && ap is null)
await _lists.DeleteConfigAsync(listId, cancellationToken); await _lists.DeleteConfigAsync(listId, cancellationToken);
@@ -58,9 +58,7 @@ public sealed class ConfigMcpTools
_ = await _tasks.GetByIdAsync(taskId, cancellationToken) _ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found."); ?? throw new InvalidOperationException($"Task {taskId} not found.");
await _tasks.UpdateAgentSettingsAsync(taskId, Nullify(model), Nullify(systemPrompt), Nullify(agentPath), cancellationToken); await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), cancellationToken);
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);
} }
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
} }

View File

@@ -111,6 +111,22 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_state = state; _state = state;
} }
// Maps the two exceptions service methods throw into client-facing HubExceptions:
// KeyNotFoundException -> notFoundMessage, InvalidOperationException -> its own message.
private static async Task HubGuard(Func<Task> action, string notFoundMessage = "task not found")
{
try { await action(); }
catch (KeyNotFoundException) { throw new HubException(notFoundMessage); }
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
}
private static async Task<T> HubGuard<T>(Func<Task<T>> action, string notFoundMessage = "task not found")
{
try { return await action(); }
catch (KeyNotFoundException) { throw new HubException(notFoundMessage); }
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
}
public async Task QueuePlanningSubtasksAsync(string parentTaskId) public async Task QueuePlanningSubtasksAsync(string parentTaskId)
{ {
try try
@@ -157,37 +173,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
} }
} }
public async Task<string> ContinueTask(string taskId, string followUpPrompt) public Task<string> ContinueTask(string taskId, string followUpPrompt)
{ => HubGuard(() => _queue.ContinueTask(taskId, followUpPrompt));
try
{
return await _queue.ContinueTask(taskId, followUpPrompt);
}
catch (InvalidOperationException ex)
{
throw new HubException(ex.Message);
}
catch (KeyNotFoundException)
{
throw new HubException("task not found");
}
}
public async Task ResetTask(string taskId) public Task ResetTask(string taskId)
{ => HubGuard(() => _resetService.ResetAsync(taskId, CancellationToken.None));
try
{
await _resetService.ResetAsync(taskId, CancellationToken.None);
}
catch (InvalidOperationException ex)
{
throw new HubException(ex.Message);
}
catch (KeyNotFoundException)
{
throw new HubException("task not found");
}
}
public bool CancelTask(string taskId) => _queue.CancelTask(taskId); public bool CancelTask(string taskId) => _queue.CancelTask(taskId);
@@ -285,10 +275,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return new ForceRemoveResultDto(result.Removed, result.Reason); return new ForceRemoveResultDto(result.Removed, result.Reason);
} }
public async Task<MergeResultDto> MergeTask( public Task<MergeResultDto> MergeTask(
string taskId, string targetBranch, bool removeWorktree, string commitMessage) string taskId, string targetBranch, bool removeWorktree, string commitMessage)
{ => HubGuard(async () =>
try
{ {
var r = await _mergeService.MergeAsync( var r = await _mergeService.MergeAsync(
taskId, taskId,
@@ -297,33 +286,14 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
string.IsNullOrWhiteSpace(commitMessage) ? "Merge task" : commitMessage, string.IsNullOrWhiteSpace(commitMessage) ? "Merge task" : commitMessage,
CancellationToken.None); CancellationToken.None);
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage); return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
} });
catch (KeyNotFoundException)
{
throw new HubException("task not found");
}
catch (InvalidOperationException ex)
{
throw new HubException(ex.Message);
}
}
public async Task<MergeTargetsDto> GetMergeTargets(string taskId) public Task<MergeTargetsDto> GetMergeTargets(string taskId)
{ => HubGuard(async () =>
try
{ {
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None); var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches); return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches);
} });
catch (KeyNotFoundException)
{
throw new HubException("task not found");
}
catch (InvalidOperationException ex)
{
throw new HubException(ex.Message);
}
}
public async Task UpdateList(UpdateListDto dto) public async Task UpdateList(UpdateListDto dto)
{ {
@@ -345,9 +315,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
using var ctx = _dbFactory.CreateDbContext(); using var ctx = _dbFactory.CreateDbContext();
var repo = new ListRepository(ctx); var repo = new ListRepository(ctx);
var model = Nullify(dto.Model); var model = dto.Model.NullIfBlank();
var systemPrompt = Nullify(dto.SystemPrompt); var systemPrompt = dto.SystemPrompt.NullIfBlank();
var agentPath = Nullify(dto.AgentPath); var agentPath = dto.AgentPath.NullIfBlank();
if (model is null && systemPrompt is null && agentPath is null) if (model is null && systemPrompt is null && agentPath is null)
{ {
@@ -394,9 +364,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var repo = new TaskRepository(ctx); var repo = new TaskRepository(ctx);
await repo.UpdateAgentSettingsAsync( await repo.UpdateAgentSettingsAsync(
dto.TaskId, dto.TaskId,
Nullify(dto.Model), dto.Model.NullIfBlank(),
Nullify(dto.SystemPrompt), dto.SystemPrompt.NullIfBlank(),
Nullify(dto.AgentPath)); dto.AgentPath.NullIfBlank());
await _broadcaster.TaskUpdated(dto.TaskId); await _broadcaster.TaskUpdated(dto.TaskId);
} }
@@ -449,21 +419,16 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
public Task<int> GetPendingDraftCountAsync(string taskId) public Task<int> GetPendingDraftCountAsync(string taskId)
=> _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted); => _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted);
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregate(string planningTaskId) public Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregate(string planningTaskId)
{ => HubGuard<IReadOnlyList<SubtaskDiffDto>>(async () =>
try
{ {
var diffs = await _planningAggregator.GetAggregatedDiffAsync(planningTaskId, CancellationToken.None); var diffs = await _planningAggregator.GetAggregatedDiffAsync(planningTaskId, CancellationToken.None);
return diffs.Select(d => new SubtaskDiffDto( return diffs.Select(d => new SubtaskDiffDto(
d.SubtaskId, d.Title, d.BranchName, d.BaseCommit, d.HeadCommit, d.DiffStat, d.UnifiedDiff)).ToList(); d.SubtaskId, d.Title, d.BranchName, d.BaseCommit, d.HeadCommit, d.DiffStat, d.UnifiedDiff)).ToList();
} }, "planning task not found");
catch (KeyNotFoundException) { throw new HubException("planning task not found"); }
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
}
public async Task<CombinedDiffResultDto> BuildPlanningIntegrationBranch(string planningTaskId, string targetBranch) public Task<CombinedDiffResultDto> BuildPlanningIntegrationBranch(string planningTaskId, string targetBranch)
{ => HubGuard(async () =>
try
{ {
var result = await _planningAggregator.BuildIntegrationBranchAsync( var result = await _planningAggregator.BuildIntegrationBranchAsync(
planningTaskId, targetBranch ?? "", CancellationToken.None); planningTaskId, targetBranch ?? "", CancellationToken.None);
@@ -475,17 +440,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
false, null, null, f.Value.FirstConflictSubtaskId, f.Value.ConflictedFiles), false, null, null, f.Value.FirstConflictSubtaskId, f.Value.ConflictedFiles),
_ => throw new InvalidOperationException("unknown result type"), _ => throw new InvalidOperationException("unknown result type"),
}; };
} }, "planning task not found");
catch (KeyNotFoundException) { throw new HubException("planning task not found"); }
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
}
public async Task MergeAllPlanning(string planningTaskId, string targetBranch) public Task MergeAllPlanning(string planningTaskId, string targetBranch)
{ => HubGuard(() => _planningMergeOrchestrator.StartAsync(planningTaskId, targetBranch ?? "", CancellationToken.None),
try { await _planningMergeOrchestrator.StartAsync(planningTaskId, targetBranch ?? "", CancellationToken.None); } "planning task not found");
catch (KeyNotFoundException) { throw new HubException("planning task not found"); }
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
}
public async Task ContinuePlanningMerge(string planningTaskId) public async Task ContinuePlanningMerge(string planningTaskId)
{ {
@@ -537,6 +496,4 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
await new PrimeScheduleRepository(ctx).DeleteAsync(id); await new PrimeScheduleRepository(ctx).DeleteAsync(id);
_primeSignal.Signal(); _primeSignal.Signal();
} }
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
} }

View File

@@ -41,6 +41,27 @@ public sealed class TaskMergeService
_logger = logger; _logger = logger;
} }
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree)> LoadMergeContextAsync(
string taskId, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
return (task, list, wt);
}
private async Task MarkWorktreeMergedAsync(string taskId, CancellationToken ct)
{
using (var ctx = _dbFactory.CreateDbContext())
{
await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct);
}
await _broadcaster.WorktreeUpdated(taskId);
}
public async Task<MergeResult> MergeAsync( public async Task<MergeResult> MergeAsync(
string taskId, string taskId,
string targetBranch, string targetBranch,
@@ -49,18 +70,7 @@ public sealed class TaskMergeService
bool leaveConflictsInTree, bool leaveConflictsInTree,
CancellationToken ct) CancellationToken ct)
{ {
TaskEntity task; var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
ListEntity list;
WorktreeEntity? wt;
using (var ctx = _dbFactory.CreateDbContext())
{
task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
}
if (task.Status == TaskStatus.Running) if (task.Status == TaskStatus.Running)
return Blocked("task is running"); return Blocked("task is running");
@@ -134,11 +144,7 @@ public sealed class TaskMergeService
} }
} }
using (var ctx = _dbFactory.CreateDbContext()) await MarkWorktreeMergedAsync(taskId, ct);
{
await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct);
}
await _broadcaster.WorktreeUpdated(taskId);
_logger.LogInformation( _logger.LogInformation(
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})", "Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
@@ -158,18 +164,7 @@ public sealed class TaskMergeService
public async Task<MergeResult> ContinueMergeAsync(string taskId, CancellationToken ct) public async Task<MergeResult> ContinueMergeAsync(string taskId, CancellationToken ct)
{ {
TaskEntity task; var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
ListEntity list;
WorktreeEntity? wt;
using (var ctx = _dbFactory.CreateDbContext())
{
task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
}
if (wt is null) return Blocked("task has no worktree"); if (wt is null) return Blocked("task has no worktree");
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}"); if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
@@ -186,11 +181,7 @@ public sealed class TaskMergeService
try { await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); } try { await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); }
catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); } catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); }
using (var ctx = _dbFactory.CreateDbContext()) await MarkWorktreeMergedAsync(taskId, ct);
{
await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct);
}
await _broadcaster.WorktreeUpdated(taskId);
_logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName); _logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName);
return new MergeResult(StatusMerged, Array.Empty<string>(), null); return new MergeResult(StatusMerged, Array.Empty<string>(), null);
@@ -198,17 +189,7 @@ public sealed class TaskMergeService
public async Task<MergeResult> AbortMergeAsync(string taskId, CancellationToken ct) public async Task<MergeResult> AbortMergeAsync(string taskId, CancellationToken ct)
{ {
ListEntity list; var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
WorktreeEntity? wt;
using (var ctx = _dbFactory.CreateDbContext())
{
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
}
if (wt is null) return Blocked("task has no worktree"); if (wt is null) return Blocked("task has no worktree");
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}"); if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
@@ -225,15 +206,7 @@ public sealed class TaskMergeService
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct) public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
{ {
TaskEntity task; var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
ListEntity list;
using (var ctx = _dbFactory.CreateDbContext())
{
task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
}
if (string.IsNullOrWhiteSpace(list.WorkingDir)) if (string.IsNullOrWhiteSpace(list.WorkingDir))
return new MergeTargets("", Array.Empty<string>()); return new MergeTargets("", Array.Empty<string>());

View File

@@ -30,40 +30,35 @@ public sealed class OverrideSlotService
{ {
using (var context = _dbFactory.CreateDbContext()) using (var context = _dbFactory.CreateDbContext())
{ {
var taskRepo = new TaskRepository(context); var exists = await new TaskRepository(context).GetByIdAsync(taskId);
var exists = await taskRepo.GetByIdAsync(taskId);
if (exists is null) if (exists is null)
throw new KeyNotFoundException($"Task '{taskId}' not found."); throw new KeyNotFoundException($"Task '{taskId}' not found.");
} }
lock (_lock) StartInSlot(taskId, ct => RunInSlotAsync(taskId, ct), "RunInSlotAsync failed for task {TaskId}");
{
if (_slot is not null)
throw new InvalidOperationException("override slot busy");
var cts = new CancellationTokenSource();
_slot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunInSlotAsync(taskId, cts.Token).ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId}", taskId);
lock (_lock) { _slot = null; }
cts.Dispose();
}, TaskScheduler.Default);
}
} }
public async Task<string> ContinueTask(string taskId, string followUpPrompt) public async Task<string> ContinueTask(string taskId, string followUpPrompt)
{ {
using var context = _dbFactory.CreateDbContext(); using (var context = _dbFactory.CreateDbContext())
var taskRepo = new TaskRepository(context); {
var task = await taskRepo.GetByIdAsync(taskId) var task = await new TaskRepository(context).GetByIdAsync(taskId)
?? throw new KeyNotFoundException($"Task '{taskId}' not found."); ?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
if (task.Status == Data.Models.TaskStatus.Running) if (task.Status == Data.Models.TaskStatus.Running)
throw new InvalidOperationException("task is already running"); throw new InvalidOperationException("task is already running");
}
StartInSlot(taskId, ct => RunContinueInSlotAsync(taskId, followUpPrompt, ct),
"RunContinueInSlotAsync failed for task {TaskId}");
return taskId;
}
// Claims the single override slot under lock, runs <work> in the background,
// and releases the slot when it completes. Throws if the slot is already busy.
private void StartInSlot(string taskId, Func<CancellationToken, Task> work, string faultMessage)
{
lock (_lock) lock (_lock)
{ {
if (_slot is not null) if (_slot is not null)
@@ -72,16 +67,14 @@ public sealed class OverrideSlotService
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
_slot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts }; _slot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(t => _ = work(cts.Token).ContinueWith(t =>
{ {
if (t.IsFaulted) if (t.IsFaulted)
_logger.LogError(t.Exception, "RunContinueInSlotAsync failed for task {TaskId}", taskId); _logger.LogError(t.Exception, faultMessage, taskId);
lock (_lock) { _slot = null; } lock (_lock) { _slot = null; }
cts.Dispose(); cts.Dispose();
}, TaskScheduler.Default); }, TaskScheduler.Default);
} }
return taskId;
} }
public bool TryCancel(string taskId) public bool TryCancel(string taskId)

View File

@@ -0,0 +1,7 @@
namespace ClaudeDo.Worker;
internal static class StringExtensions
{
/// <summary>Returns null for null/whitespace input, otherwise the original string.</summary>
public static string? NullIfBlank(this string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
}

View File

@@ -8,7 +8,7 @@ public sealed class SmartFilterTests
[Fact] [Fact]
public void MyDay_matches_my_day_tasks_regardless_of_status() public void MyDay_matches_my_day_tasks_regardless_of_status()
{ {
var f = new MyDayFilter(); var f = new SmartFlagFilter("smart:my-day", t => t.IsMyDay);
Assert.True (f.Matches(TaskFactory.Make("a", isMyDay: true, status: TaskStatus.Idle))); Assert.True (f.Matches(TaskFactory.Make("a", isMyDay: true, status: TaskStatus.Idle)));
Assert.True (f.Matches(TaskFactory.Make("b", isMyDay: true, status: TaskStatus.Done))); Assert.True (f.Matches(TaskFactory.Make("b", isMyDay: true, status: TaskStatus.Done)));
Assert.False(f.Matches(TaskFactory.Make("c", isMyDay: false, status: TaskStatus.Idle))); Assert.False(f.Matches(TaskFactory.Make("c", isMyDay: false, status: TaskStatus.Idle)));
@@ -17,7 +17,7 @@ public sealed class SmartFilterTests
[Fact] [Fact]
public void MyDay_count_excludes_done() public void MyDay_count_excludes_done()
{ {
var f = new MyDayFilter(); var f = new SmartFlagFilter("smart:my-day", t => t.IsMyDay);
Assert.True (f.ShouldCount(TaskFactory.Make("a", isMyDay: true, status: TaskStatus.Queued))); Assert.True (f.ShouldCount(TaskFactory.Make("a", isMyDay: true, status: TaskStatus.Queued)));
Assert.False(f.ShouldCount(TaskFactory.Make("b", isMyDay: true, status: TaskStatus.Done))); Assert.False(f.ShouldCount(TaskFactory.Make("b", isMyDay: true, status: TaskStatus.Done)));
Assert.False(f.ShouldCount(TaskFactory.Make("c", isMyDay: false, status: TaskStatus.Idle))); Assert.False(f.ShouldCount(TaskFactory.Make("c", isMyDay: false, status: TaskStatus.Idle)));
@@ -26,7 +26,7 @@ public sealed class SmartFilterTests
[Fact] [Fact]
public void Important_uses_IsStarred_with_same_split() public void Important_uses_IsStarred_with_same_split()
{ {
var f = new ImportantFilter(); var f = new SmartFlagFilter("smart:important", t => t.IsStarred);
Assert.True (f.Matches (TaskFactory.Make("a", isStarred: true, status: TaskStatus.Done))); Assert.True (f.Matches (TaskFactory.Make("a", isStarred: true, status: TaskStatus.Done)));
Assert.False(f.ShouldCount(TaskFactory.Make("a", isStarred: true, status: TaskStatus.Done))); Assert.False(f.ShouldCount(TaskFactory.Make("a", isStarred: true, status: TaskStatus.Done)));
Assert.True (f.ShouldCount(TaskFactory.Make("b", isStarred: true, status: TaskStatus.Queued))); Assert.True (f.ShouldCount(TaskFactory.Make("b", isStarred: true, status: TaskStatus.Queued)));
@@ -36,7 +36,7 @@ public sealed class SmartFilterTests
[Fact] [Fact]
public void Planned_uses_ScheduledFor_with_same_split() public void Planned_uses_ScheduledFor_with_same_split()
{ {
var f = new PlannedFilter(); var f = new SmartFlagFilter("smart:planned", t => t.ScheduledFor != null);
var when = DateTime.Today; var when = DateTime.Today;
Assert.True (f.Matches (TaskFactory.Make("a", scheduled: when, status: TaskStatus.Done))); Assert.True (f.Matches (TaskFactory.Make("a", scheduled: when, status: TaskStatus.Done)));
Assert.False(f.ShouldCount(TaskFactory.Make("a", scheduled: when, status: TaskStatus.Done))); Assert.False(f.ShouldCount(TaskFactory.Make("a", scheduled: when, status: TaskStatus.Done)));

View File

@@ -8,11 +8,11 @@ public sealed class TaskListFilterRegistryTests
private readonly TaskListFilterRegistry _registry = new(); private readonly TaskListFilterRegistry _registry = new();
[Theory] [Theory]
[InlineData("smart:my-day", typeof(MyDayFilter))] [InlineData("smart:my-day", typeof(SmartFlagFilter))]
[InlineData("smart:important", typeof(ImportantFilter))] [InlineData("smart:important", typeof(SmartFlagFilter))]
[InlineData("smart:planned", typeof(PlannedFilter))] [InlineData("smart:planned", typeof(SmartFlagFilter))]
[InlineData("virtual:queued", typeof(QueuedFilter))] [InlineData("virtual:queued", typeof(StatusFilter))]
[InlineData("virtual:running", typeof(RunningFilter))] [InlineData("virtual:running", typeof(StatusFilter))]
[InlineData("virtual:review", typeof(ReviewFilter))] [InlineData("virtual:review", typeof(ReviewFilter))]
public void Resolves_known_built_in_filters(string id, Type expected) public void Resolves_known_built_in_filters(string id, Type expected)
{ {

View File

@@ -11,7 +11,7 @@ public sealed class VirtualFilterTests
[Fact] [Fact]
public void Queued_matches_every_queued_task_regardless_of_parent() public void Queued_matches_every_queued_task_regardless_of_parent()
{ {
var f = new QueuedFilter(); var f = new StatusFilter("virtual:queued", TaskStatus.Queued);
Assert.True (f.Matches(TaskFactory.Make("a", status: TaskStatus.Queued))); Assert.True (f.Matches(TaskFactory.Make("a", status: TaskStatus.Queued)));
Assert.True (f.Matches(TaskFactory.Make("b", status: TaskStatus.Queued, parentId: "p"))); Assert.True (f.Matches(TaskFactory.Make("b", status: TaskStatus.Queued, parentId: "p")));
Assert.False(f.Matches(TaskFactory.Make("c", status: TaskStatus.Running))); Assert.False(f.Matches(TaskFactory.Make("c", status: TaskStatus.Running)));
@@ -21,7 +21,7 @@ public sealed class VirtualFilterTests
public void Queued_count_equals_match_for_top_level_and_children_alike() public void Queued_count_equals_match_for_top_level_and_children_alike()
{ {
// The point of the consolidation: counter must agree with display set. // The point of the consolidation: counter must agree with display set.
var f = new QueuedFilter(); var f = new StatusFilter("virtual:queued", TaskStatus.Queued);
var orphan = TaskFactory.Make("o", status: TaskStatus.Queued, parentId: "missing"); var orphan = TaskFactory.Make("o", status: TaskStatus.Queued, parentId: "missing");
Assert.True(f.Matches(orphan)); Assert.True(f.Matches(orphan));
Assert.True(f.ShouldCount(orphan)); Assert.True(f.ShouldCount(orphan));
@@ -30,7 +30,7 @@ public sealed class VirtualFilterTests
[Fact] [Fact]
public void Queued_planning_parent_with_queued_kid_is_context_match() public void Queued_planning_parent_with_queued_kid_is_context_match()
{ {
var f = new QueuedFilter(); var f = new StatusFilter("virtual:queued", TaskStatus.Queued);
var parent = TaskFactory.Make("p", phase: PlanningPhase.Active, status: TaskStatus.Done); var parent = TaskFactory.Make("p", phase: PlanningPhase.Active, status: TaskStatus.Done);
var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Queued); var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Queued);
var all = new List<TaskEntity> { parent, kid }; var all = new List<TaskEntity> { parent, kid };
@@ -42,7 +42,7 @@ public sealed class VirtualFilterTests
[Fact] [Fact]
public void Queued_non_planning_parent_is_never_context_match() public void Queued_non_planning_parent_is_never_context_match()
{ {
var f = new QueuedFilter(); var f = new StatusFilter("virtual:queued", TaskStatus.Queued);
var parent = TaskFactory.Make("p", phase: PlanningPhase.None, status: TaskStatus.Done); var parent = TaskFactory.Make("p", phase: PlanningPhase.None, status: TaskStatus.Done);
var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Queued); var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Queued);
var all = new List<TaskEntity> { parent, kid }; var all = new List<TaskEntity> { parent, kid };
@@ -53,7 +53,7 @@ public sealed class VirtualFilterTests
[Fact] [Fact]
public void Queued_planning_parent_without_queued_kid_is_not_context_match() public void Queued_planning_parent_without_queued_kid_is_not_context_match()
{ {
var f = new QueuedFilter(); var f = new StatusFilter("virtual:queued", TaskStatus.Queued);
var parent = TaskFactory.Make("p", phase: PlanningPhase.Active); var parent = TaskFactory.Make("p", phase: PlanningPhase.Active);
var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Done); var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Done);
var all = new List<TaskEntity> { parent, kid }; var all = new List<TaskEntity> { parent, kid };
@@ -66,7 +66,7 @@ public sealed class VirtualFilterTests
[Fact] [Fact]
public void Running_matches_and_context_mirror_queued() public void Running_matches_and_context_mirror_queued()
{ {
var f = new RunningFilter(); var f = new StatusFilter("virtual:running", TaskStatus.Running);
var parent = TaskFactory.Make("p", phase: PlanningPhase.Active); var parent = TaskFactory.Make("p", phase: PlanningPhase.Active);
var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Running); var kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Running);
var all = new List<TaskEntity> { parent, kid }; var all = new List<TaskEntity> { parent, kid };

View File

@@ -47,7 +47,7 @@ public class GitServiceMergeTests : IDisposable
var branches = await git.ListLocalBranchesAsync(repo.RepoDir); var branches = await git.ListLocalBranchesAsync(repo.RepoDir);
Assert.Contains("feature/x", branches); Assert.Contains("feature/x", branches);
Assert.True(branches.Any(b => b == "main" || b == "master")); Assert.Contains(branches, b => b == "main" || b == "master");
} }
[Fact] [Fact]
@@ -117,9 +117,8 @@ public class GitServiceMergeTests : IDisposable
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat: feature edit"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat: feature edit");
string defaultBranch = "main";
try { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "main"); } try { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "main"); }
catch { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "master"); defaultBranch = "master"; } catch { GitRepoFixture.RunGit(repo.RepoDir, "checkout", "master"); }
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main side\n"); File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main side\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main edit"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main edit");

View File

@@ -21,6 +21,7 @@ sealed class FakeWorkerClient : IWorkerClient
public int WakeQueueCalls { get; private set; } public int WakeQueueCalls { get; private set; }
public bool IsConnected => false; public bool IsConnected => false;
#pragma warning disable CS0067 // events required by IWorkerClient but not exercised by this fake
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged; public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
public event Action<string, string, DateTime>? TaskStartedEvent; public event Action<string, string, DateTime>? TaskStartedEvent;
public event Action<string, string, string, DateTime>? TaskFinishedEvent; public event Action<string, string, string, DateTime>? TaskFinishedEvent;
@@ -59,6 +60,7 @@ sealed class FakeWorkerClient : IWorkerClient
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent; public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
public event Action<string>? PlanningMergeAbortedEvent; public event Action<string>? PlanningMergeAbortedEvent;
public event Action<string>? PlanningCompletedEvent; public event Action<string>? PlanningCompletedEvent;
#pragma warning restore CS0067
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null); public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
public Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) => Task.FromResult<IReadOnlyList<SubtaskDiffDto>>(Array.Empty<SubtaskDiffDto>()); public Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) => Task.FromResult<IReadOnlyList<SubtaskDiffDto>>(Array.Empty<SubtaskDiffDto>());