diff --git a/src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs deleted file mode 100644 index 6ec9c62..0000000 --- a/src/ClaudeDo.Data/Filtering/Filters/ImportantFilter.cs +++ /dev/null @@ -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 all) => false; -} diff --git a/src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs deleted file mode 100644 index 63653fa..0000000 --- a/src/ClaudeDo.Data/Filtering/Filters/MyDayFilter.cs +++ /dev/null @@ -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 all) => false; -} diff --git a/src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs deleted file mode 100644 index e8cd839..0000000 --- a/src/ClaudeDo.Data/Filtering/Filters/PlannedFilter.cs +++ /dev/null @@ -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 all) => false; -} diff --git a/src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs deleted file mode 100644 index 1567601..0000000 --- a/src/ClaudeDo.Data/Filtering/Filters/QueuedFilter.cs +++ /dev/null @@ -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 all) => - PlanningRules.IsPlanningParent(t) && - PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Queued); -} diff --git a/src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs deleted file mode 100644 index 56152ad..0000000 --- a/src/ClaudeDo.Data/Filtering/Filters/RunningFilter.cs +++ /dev/null @@ -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 all) => - PlanningRules.IsPlanningParent(t) && - PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Running); -} diff --git a/src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs new file mode 100644 index 0000000..636fbd4 --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs @@ -0,0 +1,16 @@ +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Filtering.Filters; + +/// +/// Filter for a smart list keyed off a boolean/nullable task flag +/// (My Day, Important, Planned). Counts only non-done matches. +/// +public sealed class SmartFlagFilter(string id, Func 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 all) => false; +} diff --git a/src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs b/src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs new file mode 100644 index 0000000..93aa274 --- /dev/null +++ b/src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs @@ -0,0 +1,18 @@ +using ClaudeDo.Data.Models; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Filtering.Filters; + +/// +/// Virtual list filter matching tasks by a single status (Queued, Running). +/// Planning parents appear contextually when they host a matching child. +/// +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 all) => + PlanningRules.IsPlanningParent(t) && + PlanningRules.HasMatchingChild(t, all, c => c.Status == status); +} diff --git a/src/ClaudeDo.Data/Filtering/ITaskListFilter.cs b/src/ClaudeDo.Data/Filtering/Interfaces/ITaskListFilter.cs similarity index 100% rename from src/ClaudeDo.Data/Filtering/ITaskListFilter.cs rename to src/ClaudeDo.Data/Filtering/Interfaces/ITaskListFilter.cs diff --git a/src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs b/src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs index a5cfeba..c817bf4 100644 --- a/src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs +++ b/src/ClaudeDo.Data/Filtering/TaskListFilterRegistry.cs @@ -1,4 +1,5 @@ using ClaudeDo.Data.Filtering.Filters; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Data.Filtering; @@ -14,11 +15,11 @@ public sealed class TaskListFilterRegistry private static readonly IReadOnlyDictionary BuiltIn = new Dictionary(StringComparer.Ordinal) { - ["smart:my-day"] = new MyDayFilter(), - ["smart:important"] = new ImportantFilter(), - ["smart:planned"] = new PlannedFilter(), - ["virtual:queued"] = new QueuedFilter(), - ["virtual:running"] = new RunningFilter(), + ["smart:my-day"] = new SmartFlagFilter("smart:my-day", t => t.IsMyDay), + ["smart:important"] = new SmartFlagFilter("smart:important", t => t.IsStarred), + ["smart:planned"] = new SmartFlagFilter("smart:planned", t => t.ScheduledFor != null), + ["virtual:queued"] = new StatusFilter("virtual:queued", TaskStatus.Queued), + ["virtual:running"] = new StatusFilter("virtual:running", TaskStatus.Running), ["virtual:review"] = new ReviewFilter(), }; diff --git a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs index b7500d0..56ed935 100644 --- a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs +++ b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs @@ -23,7 +23,7 @@ public sealed class AppSettingsRepository return row; } - public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default) + private async Task GetOrCreateTrackedRowAsync(CancellationToken ct) { var row = await _context.AppSettings .FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct); @@ -32,6 +32,12 @@ public sealed class AppSettingsRepository row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }; _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.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel; @@ -62,13 +68,7 @@ public sealed class AppSettingsRepository public async Task SetRepoImportFoldersAsync(IEnumerable folders, CancellationToken ct = default) { var list = folders.ToList(); - var row = await _context.AppSettings - .FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct); - if (row is null) - { - row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }; - _context.AppSettings.Add(row); - } + var row = await GetOrCreateTrackedRowAsync(ct); row.RepoImportFolders = list.Count == 0 ? null : JsonSerializer.Serialize(list); await _context.SaveChangesAsync(ct); diff --git a/src/ClaudeDo.Installer/Core/ConfigModels.cs b/src/ClaudeDo.Installer/Core/ConfigModels.cs index 124c546..3705931 100644 --- a/src/ClaudeDo.Installer/Core/ConfigModels.cs +++ b/src/ClaudeDo.Installer/Core/ConfigModels.cs @@ -5,6 +5,23 @@ using ClaudeDo.Data; namespace ClaudeDo.Installer.Core; +internal static class JsonConfigFile +{ + public static T LoadOrDefault(string fileName, JsonSerializerOptions readOpts) where T : new() + { + var path = Path.Combine(Paths.AppDataRoot(), fileName); + if (!File.Exists(path)) return new(); + return JsonSerializer.Deserialize(File.ReadAllText(path), readOpts) ?? new(); + } + + public static void Save(string fileName, T value, JsonSerializerOptions writeOpts) + { + var dir = Paths.AppDataRoot(); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, fileName), JsonSerializer.Serialize(value, writeOpts)); + } +} + /// /// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape. /// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs. @@ -47,21 +64,9 @@ public sealed class InstallerWorkerConfig }; public static InstallerWorkerConfig Load() - { - var path = Path.Combine(Paths.AppDataRoot(), "worker.config.json"); - if (!File.Exists(path)) return new(); - var json = File.ReadAllText(path); - return JsonSerializer.Deserialize(json, ReadOpts) ?? new(); - } + => JsonConfigFile.LoadOrDefault("worker.config.json", ReadOpts); - public void Save() - { - 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); - } + public void Save() => JsonConfigFile.Save("worker.config.json", this, WriteOpts); } /// @@ -85,25 +90,9 @@ public sealed class InstallerAppSettings public static InstallerAppSettings Load() { - var path = Path.Combine(Paths.AppDataRoot(), "ui.config.json"); - if (!File.Exists(path)) return new(); - try - { - var json = File.ReadAllText(path); - return JsonSerializer.Deserialize(json, ReadOpts) ?? new(); - } - catch - { - return new(); - } + try { return JsonConfigFile.LoadOrDefault("ui.config.json", ReadOpts); } + catch { return new(); } } - public void Save() - { - 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); - } + public void Save() => JsonConfigFile.Save("ui.config.json", this, WriteOpts); } diff --git a/src/ClaudeDo.Installer/Core/IInstallStep.cs b/src/ClaudeDo.Installer/Core/Interfaces/IInstallStep.cs similarity index 100% rename from src/ClaudeDo.Installer/Core/IInstallStep.cs rename to src/ClaudeDo.Installer/Core/Interfaces/IInstallStep.cs diff --git a/src/ClaudeDo.Installer/Core/IInstallerPage.cs b/src/ClaudeDo.Installer/Core/Interfaces/IInstallerPage.cs similarity index 100% rename from src/ClaudeDo.Installer/Core/IInstallerPage.cs rename to src/ClaudeDo.Installer/Core/Interfaces/IInstallerPage.cs diff --git a/src/ClaudeDo.Releases/IReleaseClient.cs b/src/ClaudeDo.Releases/Interfaces/IReleaseClient.cs similarity index 100% rename from src/ClaudeDo.Releases/IReleaseClient.cs rename to src/ClaudeDo.Releases/Interfaces/IReleaseClient.cs diff --git a/src/ClaudeDo.Ui/Converters/DiffLineKindToBrushConverter.cs b/src/ClaudeDo.Ui/Converters/DiffLineKindToBrushConverter.cs index e7c1f46..9f9a240 100644 --- a/src/ClaudeDo.Ui/Converters/DiffLineKindToBrushConverter.cs +++ b/src/ClaudeDo.Ui/Converters/DiffLineKindToBrushConverter.cs @@ -7,17 +7,23 @@ namespace ClaudeDo.Ui.Converters; 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) => value is WorktreeDiffLineKind kind ? kind switch { - WorktreeDiffLineKind.Added => new SolidColorBrush(Color.Parse("#66BB6A")), - WorktreeDiffLineKind.Removed => new SolidColorBrush(Color.Parse("#EF5350")), - WorktreeDiffLineKind.Hunk => new SolidColorBrush(Color.Parse("#42A5F5")), - WorktreeDiffLineKind.Header => new SolidColorBrush(Color.Parse("#9E9E9E")), - _ => new SolidColorBrush(Color.Parse("#CFD8DC")), + WorktreeDiffLineKind.Added => Added, + WorktreeDiffLineKind.Removed => Removed, + WorktreeDiffLineKind.Hunk => Hunk, + WorktreeDiffLineKind.Header => Header, + _ => Default, } - : new SolidColorBrush(Color.Parse("#CFD8DC")); + : Default; public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException(); diff --git a/src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs b/src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs index c47833c..aa15bde 100644 --- a/src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs +++ b/src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs @@ -7,17 +7,23 @@ namespace ClaudeDo.Ui.Converters; 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) => value is WorktreeState state ? state switch { - WorktreeState.Active => new SolidColorBrush(Color.Parse("#42A5F5")), - WorktreeState.Merged => new SolidColorBrush(Color.Parse("#66BB6A")), - WorktreeState.Discarded => new SolidColorBrush(Color.Parse("#9E9E9E")), - WorktreeState.Kept => new SolidColorBrush(Color.Parse("#FFA726")), - _ => new SolidColorBrush(Colors.Gray), + WorktreeState.Active => Active, + WorktreeState.Merged => Merged, + WorktreeState.Discarded => Discarded, + WorktreeState.Kept => Kept, + _ => Default, } - : new SolidColorBrush(Colors.Gray); + : Default; public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException(); diff --git a/src/ClaudeDo.Ui/Services/InstallArtifactLocator.cs b/src/ClaudeDo.Ui/Services/InstallArtifactLocator.cs new file mode 100644 index 0000000..cead9ee --- /dev/null +++ b/src/ClaudeDo.Ui/Services/InstallArtifactLocator.cs @@ -0,0 +1,49 @@ +namespace ClaudeDo.Ui.Services; + +/// +/// 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. +/// +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; } + } +} diff --git a/src/ClaudeDo.Ui/Services/InstallerLocator.cs b/src/ClaudeDo.Ui/Services/InstallerLocator.cs index 9651810..0c6aa24 100644 --- a/src/ClaudeDo.Ui/Services/InstallerLocator.cs +++ b/src/ClaudeDo.Ui/Services/InstallerLocator.cs @@ -1,48 +1,7 @@ namespace ClaudeDo.Ui.Services; -public sealed class InstallerLocator +public sealed class InstallerLocator : InstallArtifactLocator { - private const string InstallJson = "install.json"; - private const string InstallerExe = "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; - } - } + protected override string Subdir => "uninstaller"; + protected override string ExeName => "ClaudeDo.Installer.exe"; } diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IPrimeScheduleApi.cs b/src/ClaudeDo.Ui/Services/Interfaces/IPrimeScheduleApi.cs new file mode 100644 index 0000000..2cb2aa1 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/Interfaces/IPrimeScheduleApi.cs @@ -0,0 +1,8 @@ +namespace ClaudeDo.Ui.Services; + +public interface IPrimeScheduleApi +{ + Task> ListAsync(); + Task UpsertAsync(PrimeScheduleDto dto); + Task DeleteAsync(Guid id); +} diff --git a/src/ClaudeDo.Ui/Services/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs similarity index 100% rename from src/ClaudeDo.Ui/Services/IWorkerClient.cs rename to src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index c01d2b0..c1f7fb8 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -226,6 +226,13 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC try { await _hub.StopAsync(); } catch { /* swallow */ } } + /// Invoke a hub method, returning default (null) when the worker is offline or errors. + private async Task TryInvokeAsync(string method, params object?[] args) + { + try { return await _hub.InvokeCoreAsync(method, args); } + catch { return default; } + } + public async Task RunNowAsync(string taskId) { RunNowRequestedEvent?.Invoke(taskId); @@ -248,17 +255,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC "MergeTask", taskId, targetBranch, removeWorktree, commitMessage); } - public async Task GetMergeTargetsAsync(string taskId) - { - try - { - return await _hub.InvokeAsync("GetMergeTargets", taskId); - } - catch - { - return null; - } - } + public Task GetMergeTargetsAsync(string taskId) + => TryInvokeAsync("GetMergeTargets", taskId); public async Task CancelTaskAsync(string taskId) { @@ -271,34 +269,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC } public async Task> GetAgentsAsync() - { - try - { - var agents = await _hub.InvokeAsync>("GetAgents"); - return agents ?? []; - } - catch - { - return []; - } - } + => await TryInvokeAsync>("GetAgents") ?? []; public async Task RefreshAgentsAsync() { await _hub.InvokeAsync("RefreshAgents"); } - public async Task RestoreDefaultAgentsAsync() - { - try - { - return await _hub.InvokeAsync("RestoreDefaultAgents"); - } - catch - { - return null; - } - } + public Task RestoreDefaultAgentsAsync() + => TryInvokeAsync("RestoreDefaultAgents"); private async Task SeedActiveTasksAsync() { @@ -329,17 +308,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC await _hub.DisposeAsync(); } - public async Task GetAppSettingsAsync() - { - try - { - return await _hub.InvokeAsync("GetAppSettings"); - } - catch - { - return null; - } - } + public Task GetAppSettingsAsync() + => TryInvokeAsync("GetAppSettings"); public async Task UpdateAppSettingsAsync(AppSettingsDto dto) { @@ -347,16 +317,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC } public async Task> GetPrimeSchedulesAsync() - { - try { return await _hub.InvokeAsync>("ListPrimeSchedules"); } - catch { return new List(); } - } + => await TryInvokeAsync>("ListPrimeSchedules") ?? new List(); - public async Task UpsertPrimeScheduleAsync(PrimeScheduleDto dto) - { - try { return await _hub.InvokeAsync("UpsertPrimeSchedule", dto); } - catch { return null; } - } + public Task UpsertPrimeScheduleAsync(PrimeScheduleDto dto) + => TryInvokeAsync("UpsertPrimeSchedule", dto); public async Task DeletePrimeScheduleAsync(Guid id) { @@ -374,17 +338,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC await _hub.InvokeAsync("UpdateListConfig", dto); } - public async Task GetListConfigAsync(string listId) - { - try - { - return await _hub.InvokeAsync("GetListConfig", listId); - } - catch - { - return null; - } - } + public Task GetListConfigAsync(string listId) + => TryInvokeAsync("GetListConfig", listId); public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) { @@ -396,42 +351,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); } - public async Task CleanupFinishedWorktreesAsync(string? listId = null) - { - try - { - return await _hub.InvokeAsync("CleanupFinishedWorktrees", listId); - } - catch - { - return null; - } - } + public Task CleanupFinishedWorktreesAsync(string? listId = null) + => TryInvokeAsync("CleanupFinishedWorktrees", listId); - public async Task ResetAllWorktreesAsync() - { - try - { - return await _hub.InvokeAsync("ResetAllWorktrees"); - } - catch - { - return null; - } - } + public Task ResetAllWorktreesAsync() + => TryInvokeAsync("ResetAllWorktrees"); public async Task> GetWorktreesOverviewAsync(string? listId) - { - try - { - var rows = await _hub.InvokeAsync>("GetWorktreesOverview", listId); - return rows ?? new List(); - } - catch - { - return new List(); - } - } + => await TryInvokeAsync>("GetWorktreesOverview", listId) + ?? new List(); 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 ForceRemoveWorktreeAsync(string taskId) - { - try - { - return await _hub.InvokeAsync("ForceRemoveWorktree", taskId); - } - catch - { - return null; - } - } + public Task ForceRemoveWorktreeAsync(string taskId) + => TryInvokeAsync("ForceRemoveWorktree", taskId); public async Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => await _hub.InvokeAsync("StartPlanningSessionAsync", taskId, ct); @@ -481,29 +400,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC => await _hub.InvokeAsync("GetPendingDraftCountAsync", taskId, ct); public async Task> GetPlanningAggregateAsync(string planningTaskId) - { - try - { - var result = await _hub.InvokeAsync>("GetPlanningAggregate", planningTaskId); - return result ?? []; - } - catch - { - return []; - } - } + => await TryInvokeAsync>("GetPlanningAggregate", planningTaskId) ?? []; - public async Task BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) - { - try - { - return await _hub.InvokeAsync("BuildPlanningIntegrationBranch", planningTaskId, targetBranch); - } - catch - { - return null; - } - } + public Task BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) + => TryInvokeAsync("BuildPlanningIntegrationBranch", planningTaskId, targetBranch); public async Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) { diff --git a/src/ClaudeDo.Ui/Services/WorkerLocator.cs b/src/ClaudeDo.Ui/Services/WorkerLocator.cs index 074601e..71e1954 100644 --- a/src/ClaudeDo.Ui/Services/WorkerLocator.cs +++ b/src/ClaudeDo.Ui/Services/WorkerLocator.cs @@ -1,43 +1,7 @@ namespace ClaudeDo.Ui.Services; -public sealed class WorkerLocator +public sealed class WorkerLocator : InstallArtifactLocator { - private const string InstallJson = "install.json"; - private const string WorkerExe = "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; } - } + protected override string Subdir => "worker"; + protected override string ExeName => "ClaudeDo.Worker.exe"; } diff --git a/src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs b/src/ClaudeDo.Ui/Services/WorkerPrimeScheduleApi.cs similarity index 73% rename from src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs rename to src/ClaudeDo.Ui/Services/WorkerPrimeScheduleApi.cs index 8351898..ed72072 100644 --- a/src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs +++ b/src/ClaudeDo.Ui/Services/WorkerPrimeScheduleApi.cs @@ -1,12 +1,5 @@ namespace ClaudeDo.Ui.Services; -public interface IPrimeScheduleApi -{ - Task> ListAsync(); - Task UpsertAsync(PrimeScheduleDto dto); - Task DeleteAsync(Guid id); -} - public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi { private readonly WorkerClient _client; diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index beb2fa1..0024e4a 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -36,9 +36,9 @@ public sealed class ConfigMcpTools _ = await _lists.GetByIdAsync(listId, cancellationToken) ?? throw new InvalidOperationException($"List {listId} not found."); - var m = Nullify(model); - var sp = Nullify(systemPrompt); - var ap = Nullify(agentPath); + var m = model.NullIfBlank(); + var sp = systemPrompt.NullIfBlank(); + var ap = agentPath.NullIfBlank(); if (m is null && sp is null && ap is null) await _lists.DeleteConfigAsync(listId, cancellationToken); @@ -58,9 +58,7 @@ public sealed class ConfigMcpTools _ = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? 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); } - - private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s; } diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index d03bd01..f9acbc5 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -111,6 +111,22 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub _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 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 HubGuard(Func> 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) { try @@ -157,37 +173,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub } } - public async Task ContinueTask(string taskId, string 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 Task ContinueTask(string taskId, string followUpPrompt) + => HubGuard(() => _queue.ContinueTask(taskId, followUpPrompt)); - public async Task ResetTask(string taskId) - { - try - { - await _resetService.ResetAsync(taskId, CancellationToken.None); - } - catch (InvalidOperationException ex) - { - throw new HubException(ex.Message); - } - catch (KeyNotFoundException) - { - throw new HubException("task not found"); - } - } + public Task ResetTask(string taskId) + => HubGuard(() => _resetService.ResetAsync(taskId, CancellationToken.None)); 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); } - public async Task MergeTask( + public Task MergeTask( string taskId, string targetBranch, bool removeWorktree, string commitMessage) - { - try + => HubGuard(async () => { var r = await _mergeService.MergeAsync( taskId, @@ -297,33 +286,14 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub string.IsNullOrWhiteSpace(commitMessage) ? "Merge task" : commitMessage, CancellationToken.None); 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 GetMergeTargets(string taskId) - { - try + public Task GetMergeTargets(string taskId) + => HubGuard(async () => { var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None); 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) { @@ -345,9 +315,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub using var ctx = _dbFactory.CreateDbContext(); var repo = new ListRepository(ctx); - var model = Nullify(dto.Model); - var systemPrompt = Nullify(dto.SystemPrompt); - var agentPath = Nullify(dto.AgentPath); + var model = dto.Model.NullIfBlank(); + var systemPrompt = dto.SystemPrompt.NullIfBlank(); + var agentPath = dto.AgentPath.NullIfBlank(); 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); await repo.UpdateAgentSettingsAsync( dto.TaskId, - Nullify(dto.Model), - Nullify(dto.SystemPrompt), - Nullify(dto.AgentPath)); + dto.Model.NullIfBlank(), + dto.SystemPrompt.NullIfBlank(), + dto.AgentPath.NullIfBlank()); await _broadcaster.TaskUpdated(dto.TaskId); } @@ -449,21 +419,16 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub public Task GetPendingDraftCountAsync(string taskId) => _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted); - public async Task> GetPlanningAggregate(string planningTaskId) - { - try + public Task> GetPlanningAggregate(string planningTaskId) + => HubGuard>(async () => { var diffs = await _planningAggregator.GetAggregatedDiffAsync(planningTaskId, CancellationToken.None); return diffs.Select(d => new SubtaskDiffDto( d.SubtaskId, d.Title, d.BranchName, d.BaseCommit, d.HeadCommit, d.DiffStat, d.UnifiedDiff)).ToList(); - } - catch (KeyNotFoundException) { throw new HubException("planning task not found"); } - catch (InvalidOperationException ex) { throw new HubException(ex.Message); } - } + }, "planning task not found"); - public async Task BuildPlanningIntegrationBranch(string planningTaskId, string targetBranch) - { - try + public Task BuildPlanningIntegrationBranch(string planningTaskId, string targetBranch) + => HubGuard(async () => { var result = await _planningAggregator.BuildIntegrationBranchAsync( 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), _ => throw new InvalidOperationException("unknown result type"), }; - } - catch (KeyNotFoundException) { throw new HubException("planning task not found"); } - catch (InvalidOperationException ex) { throw new HubException(ex.Message); } - } + }, "planning task not found"); - public async Task MergeAllPlanning(string planningTaskId, string targetBranch) - { - try { await _planningMergeOrchestrator.StartAsync(planningTaskId, targetBranch ?? "", CancellationToken.None); } - catch (KeyNotFoundException) { throw new HubException("planning task not found"); } - catch (InvalidOperationException ex) { throw new HubException(ex.Message); } - } + public Task MergeAllPlanning(string planningTaskId, string targetBranch) + => HubGuard(() => _planningMergeOrchestrator.StartAsync(planningTaskId, targetBranch ?? "", CancellationToken.None), + "planning task not found"); public async Task ContinuePlanningMerge(string planningTaskId) { @@ -537,6 +496,4 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub await new PrimeScheduleRepository(ctx).DeleteAsync(id); _primeSignal.Signal(); } - - private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s; } diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 8fd8010..18db0bd 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -41,6 +41,27 @@ public sealed class TaskMergeService _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 MergeAsync( string taskId, string targetBranch, @@ -49,18 +70,7 @@ public sealed class TaskMergeService bool leaveConflictsInTree, CancellationToken ct) { - TaskEntity task; - 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); - } + var (task, list, wt) = await LoadMergeContextAsync(taskId, ct); if (task.Status == TaskStatus.Running) return Blocked("task is running"); @@ -134,11 +144,7 @@ public sealed class TaskMergeService } } - using (var ctx = _dbFactory.CreateDbContext()) - { - await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct); - } - await _broadcaster.WorktreeUpdated(taskId); + await MarkWorktreeMergedAsync(taskId, ct); _logger.LogInformation( "Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})", @@ -158,18 +164,7 @@ public sealed class TaskMergeService public async Task ContinueMergeAsync(string taskId, CancellationToken ct) { - TaskEntity task; - 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); - } + var (_, list, wt) = await LoadMergeContextAsync(taskId, ct); if (wt is null) return Blocked("task has no worktree"); 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); } catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); } - using (var ctx = _dbFactory.CreateDbContext()) - { - await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct); - } - await _broadcaster.WorktreeUpdated(taskId); + await MarkWorktreeMergedAsync(taskId, ct); _logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName); return new MergeResult(StatusMerged, Array.Empty(), null); @@ -198,17 +189,7 @@ public sealed class TaskMergeService public async Task AbortMergeAsync(string taskId, CancellationToken ct) { - ListEntity list; - 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); - } + var (_, list, wt) = await LoadMergeContextAsync(taskId, ct); if (wt is null) return Blocked("task has no worktree"); if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}"); @@ -225,15 +206,7 @@ public sealed class TaskMergeService public async Task GetTargetsAsync(string taskId, CancellationToken ct) { - TaskEntity task; - 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."); - } + var (_, list, _) = await LoadMergeContextAsync(taskId, ct); if (string.IsNullOrWhiteSpace(list.WorkingDir)) return new MergeTargets("", Array.Empty()); diff --git a/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/Interfaces/IPlanningTerminalLauncher.cs similarity index 100% rename from src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs rename to src/ClaudeDo.Worker/Planning/Interfaces/IPlanningTerminalLauncher.cs diff --git a/src/ClaudeDo.Worker/Prime/IPrimeBroadcaster.cs b/src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs similarity index 100% rename from src/ClaudeDo.Worker/Prime/IPrimeBroadcaster.cs rename to src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs diff --git a/src/ClaudeDo.Worker/Prime/IPrimeClock.cs b/src/ClaudeDo.Worker/Prime/Interfaces/IPrimeClock.cs similarity index 100% rename from src/ClaudeDo.Worker/Prime/IPrimeClock.cs rename to src/ClaudeDo.Worker/Prime/Interfaces/IPrimeClock.cs diff --git a/src/ClaudeDo.Worker/Prime/IPrimeRunner.cs b/src/ClaudeDo.Worker/Prime/Interfaces/IPrimeRunner.cs similarity index 100% rename from src/ClaudeDo.Worker/Prime/IPrimeRunner.cs rename to src/ClaudeDo.Worker/Prime/Interfaces/IPrimeRunner.cs diff --git a/src/ClaudeDo.Worker/Prime/IPrimeScheduleSignal.cs b/src/ClaudeDo.Worker/Prime/Interfaces/IPrimeScheduleSignal.cs similarity index 100% rename from src/ClaudeDo.Worker/Prime/IPrimeScheduleSignal.cs rename to src/ClaudeDo.Worker/Prime/Interfaces/IPrimeScheduleSignal.cs diff --git a/src/ClaudeDo.Worker/Queue/IQueuePicker.cs b/src/ClaudeDo.Worker/Queue/Interfaces/IQueuePicker.cs similarity index 100% rename from src/ClaudeDo.Worker/Queue/IQueuePicker.cs rename to src/ClaudeDo.Worker/Queue/Interfaces/IQueuePicker.cs diff --git a/src/ClaudeDo.Worker/Queue/IQueueWaker.cs b/src/ClaudeDo.Worker/Queue/Interfaces/IQueueWaker.cs similarity index 100% rename from src/ClaudeDo.Worker/Queue/IQueueWaker.cs rename to src/ClaudeDo.Worker/Queue/Interfaces/IQueueWaker.cs diff --git a/src/ClaudeDo.Worker/Queue/OverrideSlotService.cs b/src/ClaudeDo.Worker/Queue/OverrideSlotService.cs index 4b4fb45..6c95684 100644 --- a/src/ClaudeDo.Worker/Queue/OverrideSlotService.cs +++ b/src/ClaudeDo.Worker/Queue/OverrideSlotService.cs @@ -30,40 +30,35 @@ public sealed class OverrideSlotService { using (var context = _dbFactory.CreateDbContext()) { - var taskRepo = new TaskRepository(context); - var exists = await taskRepo.GetByIdAsync(taskId); + var exists = await new TaskRepository(context).GetByIdAsync(taskId); if (exists is null) throw new KeyNotFoundException($"Task '{taskId}' not found."); } - lock (_lock) - { - 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); - } + StartInSlot(taskId, ct => RunInSlotAsync(taskId, ct), "RunInSlotAsync failed for task {TaskId}"); } public async Task ContinueTask(string taskId, string followUpPrompt) { - using var context = _dbFactory.CreateDbContext(); - var taskRepo = new TaskRepository(context); - var task = await taskRepo.GetByIdAsync(taskId) - ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); + using (var context = _dbFactory.CreateDbContext()) + { + var task = await new TaskRepository(context).GetByIdAsync(taskId) + ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); - if (task.Status == Data.Models.TaskStatus.Running) - throw new InvalidOperationException("task is already running"); + if (task.Status == Data.Models.TaskStatus.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 in the background, + // and releases the slot when it completes. Throws if the slot is already busy. + private void StartInSlot(string taskId, Func work, string faultMessage) + { lock (_lock) { if (_slot is not null) @@ -72,16 +67,14 @@ public sealed class OverrideSlotService var cts = new CancellationTokenSource(); _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) - _logger.LogError(t.Exception, "RunContinueInSlotAsync failed for task {TaskId}", taskId); + _logger.LogError(t.Exception, faultMessage, taskId); lock (_lock) { _slot = null; } cts.Dispose(); }, TaskScheduler.Default); } - - return taskId; } public bool TryCancel(string taskId) diff --git a/src/ClaudeDo.Worker/Runner/IClaudeProcess.cs b/src/ClaudeDo.Worker/Runner/Interfaces/IClaudeProcess.cs similarity index 100% rename from src/ClaudeDo.Worker/Runner/IClaudeProcess.cs rename to src/ClaudeDo.Worker/Runner/Interfaces/IClaudeProcess.cs diff --git a/src/ClaudeDo.Worker/State/ITaskStateService.cs b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs similarity index 100% rename from src/ClaudeDo.Worker/State/ITaskStateService.cs rename to src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs diff --git a/src/ClaudeDo.Worker/StringExtensions.cs b/src/ClaudeDo.Worker/StringExtensions.cs new file mode 100644 index 0000000..072dd84 --- /dev/null +++ b/src/ClaudeDo.Worker/StringExtensions.cs @@ -0,0 +1,7 @@ +namespace ClaudeDo.Worker; + +internal static class StringExtensions +{ + /// Returns null for null/whitespace input, otherwise the original string. + public static string? NullIfBlank(this string? s) => string.IsNullOrWhiteSpace(s) ? null : s; +} diff --git a/tests/ClaudeDo.Data.Tests/Filtering/SmartFilterTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/SmartFilterTests.cs index a80a49e..22ddec9 100644 --- a/tests/ClaudeDo.Data.Tests/Filtering/SmartFilterTests.cs +++ b/tests/ClaudeDo.Data.Tests/Filtering/SmartFilterTests.cs @@ -8,7 +8,7 @@ public sealed class SmartFilterTests [Fact] 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("b", isMyDay: true, status: TaskStatus.Done))); Assert.False(f.Matches(TaskFactory.Make("c", isMyDay: false, status: TaskStatus.Idle))); @@ -17,7 +17,7 @@ public sealed class SmartFilterTests [Fact] 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.False(f.ShouldCount(TaskFactory.Make("b", isMyDay: true, status: TaskStatus.Done))); Assert.False(f.ShouldCount(TaskFactory.Make("c", isMyDay: false, status: TaskStatus.Idle))); @@ -26,7 +26,7 @@ public sealed class SmartFilterTests [Fact] 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.False(f.ShouldCount(TaskFactory.Make("a", isStarred: true, status: TaskStatus.Done))); Assert.True (f.ShouldCount(TaskFactory.Make("b", isStarred: true, status: TaskStatus.Queued))); @@ -36,7 +36,7 @@ public sealed class SmartFilterTests [Fact] 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; Assert.True (f.Matches (TaskFactory.Make("a", scheduled: when, status: TaskStatus.Done))); Assert.False(f.ShouldCount(TaskFactory.Make("a", scheduled: when, status: TaskStatus.Done))); diff --git a/tests/ClaudeDo.Data.Tests/Filtering/TaskListFilterRegistryTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/TaskListFilterRegistryTests.cs index 8cbe145..c3b9958 100644 --- a/tests/ClaudeDo.Data.Tests/Filtering/TaskListFilterRegistryTests.cs +++ b/tests/ClaudeDo.Data.Tests/Filtering/TaskListFilterRegistryTests.cs @@ -8,11 +8,11 @@ public sealed class TaskListFilterRegistryTests private readonly TaskListFilterRegistry _registry = new(); [Theory] - [InlineData("smart:my-day", typeof(MyDayFilter))] - [InlineData("smart:important", typeof(ImportantFilter))] - [InlineData("smart:planned", typeof(PlannedFilter))] - [InlineData("virtual:queued", typeof(QueuedFilter))] - [InlineData("virtual:running", typeof(RunningFilter))] + [InlineData("smart:my-day", typeof(SmartFlagFilter))] + [InlineData("smart:important", typeof(SmartFlagFilter))] + [InlineData("smart:planned", typeof(SmartFlagFilter))] + [InlineData("virtual:queued", typeof(StatusFilter))] + [InlineData("virtual:running", typeof(StatusFilter))] [InlineData("virtual:review", typeof(ReviewFilter))] public void Resolves_known_built_in_filters(string id, Type expected) { diff --git a/tests/ClaudeDo.Data.Tests/Filtering/VirtualFilterTests.cs b/tests/ClaudeDo.Data.Tests/Filtering/VirtualFilterTests.cs index 8494f97..0f97f71 100644 --- a/tests/ClaudeDo.Data.Tests/Filtering/VirtualFilterTests.cs +++ b/tests/ClaudeDo.Data.Tests/Filtering/VirtualFilterTests.cs @@ -11,7 +11,7 @@ public sealed class VirtualFilterTests [Fact] 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("b", status: TaskStatus.Queued, parentId: "p"))); 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() { // 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"); Assert.True(f.Matches(orphan)); Assert.True(f.ShouldCount(orphan)); @@ -30,7 +30,7 @@ public sealed class VirtualFilterTests [Fact] 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 kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Queued); var all = new List { parent, kid }; @@ -42,7 +42,7 @@ public sealed class VirtualFilterTests [Fact] 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 kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Queued); var all = new List { parent, kid }; @@ -53,7 +53,7 @@ public sealed class VirtualFilterTests [Fact] 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 kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Done); var all = new List { parent, kid }; @@ -66,7 +66,7 @@ public sealed class VirtualFilterTests [Fact] 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 kid = TaskFactory.Make("k", parentId: "p", status: TaskStatus.Running); var all = new List { parent, kid }; diff --git a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs index 323b1f9..f10528f 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/GitServiceMergeTests.cs @@ -47,7 +47,7 @@ public class GitServiceMergeTests : IDisposable var branches = await git.ListLocalBranchesAsync(repo.RepoDir); Assert.Contains("feature/x", branches); - Assert.True(branches.Any(b => b == "main" || b == "master")); + Assert.Contains(branches, b => b == "main" || b == "master"); } [Fact] @@ -117,9 +117,8 @@ public class GitServiceMergeTests : IDisposable GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat: feature edit"); - string defaultBranch = "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"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main edit"); diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 71558aa..0e2d26e 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -21,6 +21,7 @@ sealed class FakeWorkerClient : IWorkerClient public int WakeQueueCalls { get; private set; } 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 Action? TaskStartedEvent; public event Action? TaskFinishedEvent; @@ -59,6 +60,7 @@ sealed class FakeWorkerClient : IWorkerClient public event Action>? PlanningMergeConflictEvent; public event Action? PlanningMergeAbortedEvent; public event Action? PlanningCompletedEvent; +#pragma warning restore CS0067 public Task GetMergeTargetsAsync(string taskId) => Task.FromResult(null); public Task> GetPlanningAggregateAsync(string planningTaskId) => Task.FromResult>(Array.Empty());