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:
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
16
src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs
Normal file
16
src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs
Normal 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;
|
||||||
|
}
|
||||||
18
src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs
Normal file
18
src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
49
src/ClaudeDo.Ui/Services/InstallArtifactLocator.cs
Normal file
49
src/ClaudeDo.Ui/Services/InstallArtifactLocator.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/ClaudeDo.Ui/Services/Interfaces/IPrimeScheduleApi.cs
Normal file
8
src/ClaudeDo.Ui/Services/Interfaces/IPrimeScheduleApi.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
10
src/ClaudeDo.Worker/External/ConfigMcpTools.cs
vendored
10
src/ClaudeDo.Worker/External/ConfigMcpTools.cs
vendored
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>());
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
7
src/ClaudeDo.Worker/StringExtensions.cs
Normal file
7
src/ClaudeDo.Worker/StringExtensions.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>());
|
||||||
|
|||||||
Reference in New Issue
Block a user