refactor: collapse single-implementation interfaces

Nine interfaces had exactly one implementation and no test double — they existed
only to be named twice in a DI registration: IFindingsStore, IFindingsStoreLocator,
IPrimeScheduleSignal, IRefineRunner, IWeekReportService, IMergeCoordinator,
IMissionControlPane, IOnlineLoginService, ITaskListFilter. Consumers now depend on
the concrete type; the DTO records that shared those files moved next to their
implementation. IInteractiveLaunchSpecService stays — it carries 54 lines of
contract documentation, which is not ceremony.

IMergeCoordinator in particular had a redundant null object: MergeCoordinator with
a null Handler already no-ops, and every test used the real class with Handler set.

Filtering/ collapses from 8 files to 1. ITaskListFilter and TaskListFilterBase were
a double abstraction over four predicates, with MatchesAsContext => false declared
in both. SmartFlagFilter also compiled its expression twice (its own _flag plus the
inherited Matches cache) — it now uses the cache.

StaticTokenAuthProvider was in src but production uses ZitadelAuthProvider; it is
a test double, so it moves to the test project. Its own test goes away with it.
This commit is contained in:
mika kuns
2026-08-26 10:12:23 +02:00
parent 76d836a278
commit 296251e27e
45 changed files with 174 additions and 327 deletions
+2 -3
View File
@@ -128,7 +128,6 @@ sealed class Program
// Conflict-merge coordinator: single seam the shell wires to its resolver entry. // Conflict-merge coordinator: single seam the shell wires to its resolver entry.
sc.AddSingleton<MergeCoordinator>(); sc.AddSingleton<MergeCoordinator>();
sc.AddSingleton<IMergeCoordinator>(sp => sp.GetRequiredService<MergeCoordinator>());
// ViewModels // ViewModels
sc.AddTransient<DiffViewerViewModel>(); sc.AddTransient<DiffViewerViewModel>();
@@ -139,7 +138,7 @@ sealed class Program
sc.AddTransient<MergeHelperSelectionModalViewModel>(); sc.AddTransient<MergeHelperSelectionModalViewModel>();
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>(); sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
sc.AddSingleton<INotesApi, WorkerNotesApi>(); sc.AddSingleton<INotesApi, WorkerNotesApi>();
sc.AddSingleton<IOnlineLoginService, OnlineLoginService>(); sc.AddSingleton<OnlineLoginService>();
sc.AddTransient<PrimeClaudeTabViewModel>(); sc.AddTransient<PrimeClaudeTabViewModel>();
sc.AddTransient<SettingsModalViewModel>(); sc.AddTransient<SettingsModalViewModel>();
sc.AddTransient<MergeModalViewModel>(); sc.AddTransient<MergeModalViewModel>();
@@ -173,7 +172,7 @@ sealed class Program
sp.GetRequiredService<IWorkerClient>(), sp.GetRequiredService<IWorkerClient>(),
sp, sp,
sp.GetRequiredService<INotesApi>(), sp.GetRequiredService<INotesApi>(),
sp.GetRequiredService<IMergeCoordinator>())); sp.GetRequiredService<MergeCoordinator>()));
sc.AddSingleton<UsagePillViewModel>(sp => sc.AddSingleton<UsagePillViewModel>(sp =>
new UsagePillViewModel(sp.GetRequiredService<IWorkerClient>())); new UsagePillViewModel(sp.GetRequiredService<IWorkerClient>()));
sc.AddSingleton<MissionControlViewModel>(sp => sc.AddSingleton<MissionControlViewModel>(sp =>
@@ -1,12 +0,0 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class ReviewFilter : TaskListFilterBase
{
public override string Id => "virtual:review";
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == TaskStatus.WaitingForReview;
public override bool ShouldCount(TaskEntity t) => Matches(t);
}
@@ -1,18 +0,0 @@
using System.Linq.Expressions;
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, Expression<Func<TaskEntity, bool>> flag) : TaskListFilterBase
{
private readonly Func<TaskEntity, bool> _flag = flag.Compile();
public override string Id => id;
protected override Expression<Func<TaskEntity, bool>> MatchExpr => flag;
public override bool ShouldCount(TaskEntity t) => _flag(t) && t.Status != TaskStatus.Done;
}
@@ -1,19 +0,0 @@
using System.Linq.Expressions;
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) : TaskListFilterBase
{
public override string Id => id;
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == status;
public override bool ShouldCount(TaskEntity t) => t.Status == status;
public override bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
}
@@ -1,27 +0,0 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Base for <see cref="ITaskListFilter"/> implementations: subclasses express their
/// primary-match condition once as an expression tree (<see cref="MatchExpr"/>), which
/// doubles as a SQL-translatable predicate (<see cref="MatchExpression"/>) and, compiled
/// on first use, as the in-memory <see cref="Matches"/> predicate.
/// </summary>
public abstract class TaskListFilterBase : ITaskListFilter
{
private Func<TaskEntity, bool>? _compiled;
public abstract string Id { get; }
protected abstract Expression<Func<TaskEntity, bool>> MatchExpr { get; }
public Expression<Func<TaskEntity, bool>> MatchExpression => MatchExpr;
public bool Matches(TaskEntity t) => (_compiled ??= MatchExpr.Compile())(t);
public abstract bool ShouldCount(TaskEntity t);
public virtual bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}
@@ -1,24 +0,0 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Filter for any user-defined list. Constructed on demand from the list id —
/// one instance per list.
/// </summary>
public sealed class UserListFilter : TaskListFilterBase
{
private readonly string _listId;
public UserListFilter(string listId)
{
_listId = listId;
Id = $"user:{listId}";
}
public override string Id { get; }
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.ListId == _listId;
public override bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
}
@@ -1,30 +0,0 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering;
/// <summary>
/// Strategy that defines which tasks belong to a single list. One implementation
/// per list kind; consumers (counters, list loader) ask the registry for the
/// right strategy and never branch on the list id themselves.
/// </summary>
public interface ITaskListFilter
{
/// <summary>The list id this filter applies to (e.g. "virtual:queued", "user:abc").</summary>
string Id { get; }
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
bool Matches(TaskEntity t);
/// <summary>The primary-match predicate as an expression tree, so EF Core can push it into SQL.</summary>
Expression<Func<TaskEntity, bool>> MatchExpression { get; }
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
bool ShouldCount(TaskEntity t);
/// <summary>
/// True if <paramref name="t"/> is shown as a contextual row (not a primary citizen,
/// but appears to host children that match). Default: nothing extra.
/// </summary>
bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}
@@ -1,8 +1,84 @@
using ClaudeDo.Data.Filtering.Filters; using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering; namespace ClaudeDo.Data.Filtering;
/// <summary>
/// Strategy that defines which tasks belong to a single list. One subclass per list kind;
/// consumers (counters, list loader) ask the registry for the right strategy and never branch
/// on the list id themselves.
///
/// Subclasses express their primary-match condition once as an expression tree
/// (<see cref="MatchExpression"/>), which doubles as a SQL-translatable predicate for EF Core
/// and, compiled on first use, as the in-memory <see cref="Matches"/> predicate.
/// </summary>
public abstract class TaskListFilter
{
private Func<TaskEntity, bool>? _compiled;
/// <summary>The list id this filter applies to (e.g. "virtual:queued", "user:abc").</summary>
public abstract string Id { get; }
/// <summary>The primary-match predicate as an expression tree, so EF Core can push it into SQL.</summary>
public abstract Expression<Func<TaskEntity, bool>> MatchExpression { get; }
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
public bool Matches(TaskEntity t) => (_compiled ??= MatchExpression.Compile())(t);
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
public abstract bool ShouldCount(TaskEntity t);
/// <summary>
/// True if <paramref name="t"/> is shown as a contextual row (not a primary citizen,
/// but appears to host children that match). Default: nothing extra.
/// </summary>
public virtual bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}
/// <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, Expression<Func<TaskEntity, bool>> flag) : TaskListFilter
{
public override string Id => id;
public override Expression<Func<TaskEntity, bool>> MatchExpression => flag;
public override bool ShouldCount(TaskEntity t) => Matches(t) && t.Status != TaskStatus.Done;
}
/// <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) : TaskListFilter
{
public override string Id => id;
public override Expression<Func<TaskEntity, bool>> MatchExpression => t => t.Status == status;
public override bool ShouldCount(TaskEntity t) => t.Status == status;
public override bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
}
public sealed class ReviewFilter : TaskListFilter
{
public override string Id => "virtual:review";
public override Expression<Func<TaskEntity, bool>> MatchExpression => t => t.Status == TaskStatus.WaitingForReview;
public override bool ShouldCount(TaskEntity t) => Matches(t);
}
/// <summary>
/// Filter for any user-defined list. Constructed on demand from the list id —
/// one instance per list.
/// </summary>
public sealed class UserListFilter(string listId) : TaskListFilter
{
public override string Id { get; } = $"user:{listId}";
public override Expression<Func<TaskEntity, bool>> MatchExpression => t => t.ListId == listId;
public override bool ShouldCount(TaskEntity t) => t.ListId == listId && t.Status != TaskStatus.Done;
}
/// <summary> /// <summary>
/// Resolves a list id (e.g. "virtual:queued", "user:abc") to the filter that /// Resolves a list id (e.g. "virtual:queued", "user:abc") to the filter that
/// owns its semantics. Smart and virtual filters are singletons; user-list /// owns its semantics. Smart and virtual filters are singletons; user-list
@@ -12,8 +88,8 @@ public sealed class TaskListFilterRegistry
{ {
public const string UserListPrefix = "user:"; public const string UserListPrefix = "user:";
private static readonly IReadOnlyDictionary<string, ITaskListFilter> BuiltIn = private static readonly IReadOnlyDictionary<string, TaskListFilter> BuiltIn =
new Dictionary<string, ITaskListFilter>(StringComparer.Ordinal) new Dictionary<string, TaskListFilter>(StringComparer.Ordinal)
{ {
["smart:my-day"] = new SmartFlagFilter("smart:my-day", t => t.IsMyDay), ["smart:my-day"] = new SmartFlagFilter("smart:my-day", t => t.IsMyDay),
["smart:important"] = new SmartFlagFilter("smart:important", t => t.IsStarred), ["smart:important"] = new SmartFlagFilter("smart:important", t => t.IsStarred),
@@ -26,7 +102,7 @@ public sealed class TaskListFilterRegistry
/// <summary> /// <summary>
/// Resolve a filter for a list id, or null if the id is unknown. /// Resolve a filter for a list id, or null if the id is unknown.
/// </summary> /// </summary>
public ITaskListFilter? Resolve(string listId) public TaskListFilter? Resolve(string listId)
{ {
if (BuiltIn.TryGetValue(listId, out var f)) return f; if (BuiltIn.TryGetValue(listId, out var f)) return f;
if (listId.StartsWith(UserListPrefix, StringComparison.Ordinal)) if (listId.StartsWith(UserListPrefix, StringComparison.Ordinal))
@@ -1,10 +0,0 @@
namespace ClaudeDo.Ui.Services;
public sealed record OnlineLoginResult(bool Success, string? RefreshToken, string? Error, string? Warning = null);
public interface IOnlineLoginService
{
Task<OnlineLoginResult> LoginAsync(
string authority, string clientId, string scope, string redirectUri,
CancellationToken ct = default);
}
@@ -6,20 +6,12 @@ namespace ClaudeDo.Ui.Services;
/// <summary> /// <summary>
/// Single entry point for handing a conflicting merge to the in-app 3-pane resolver. /// Single entry point for handing a conflicting merge to the in-app 3-pane resolver.
/// Replaces the per-VM <c>RequestConflictResolution</c> Func seams that used to be /// Replaces the per-VM <c>RequestConflictResolution</c> Func seams that used to be
/// hand-threaded shell → details → merge-section → diff → merge-modal. The shell wires /// hand-threaded shell → details → merge-section → diff → merge-modal.
/// <see cref="MergeCoordinator.Handler"/> once at composition; invokers depend only on ///
/// this interface (injected via DI).
/// </summary>
public interface IMergeCoordinator
{
Task ResolveConflictAsync(string taskId, string targetBranch);
}
/// <summary>
/// DI singleton holding the resolver entry. The holder breaks the shell↔island construction /// DI singleton holding the resolver entry. The holder breaks the shell↔island construction
/// cycle: islands depend on the interface, the shell sets <see cref="Handler"/> after it is built. /// cycle: islands depend on this type, the shell sets <see cref="Handler"/> after it is built.
/// </summary> /// </summary>
public sealed class MergeCoordinator : IMergeCoordinator public sealed class MergeCoordinator
{ {
/// Set once at composition to the shell's resolver entry. Null (headless/tests) ⇒ no-op. /// Set once at composition to the shell's resolver entry. Null (headless/tests) ⇒ no-op.
public Func<string, string, Task>? Handler { get; set; } public Func<string, string, Task>? Handler { get; set; }
@@ -6,7 +6,9 @@ using Duende.IdentityModel.OidcClient.Browser;
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
public sealed class OnlineLoginService : IOnlineLoginService public sealed record OnlineLoginResult(bool Success, string? RefreshToken, string? Error, string? Warning = null);
public sealed class OnlineLoginService
{ {
public async Task<OnlineLoginResult> LoginAsync( public async Task<OnlineLoginResult> LoginAsync(
string authority, string clientId, string scope, string redirectUri, string authority, string clientId, string scope, string redirectUri,
@@ -21,7 +21,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
private readonly IServiceProvider _services; private readonly IServiceProvider _services;
private readonly INotesApi _notesApi; private readonly INotesApi _notesApi;
private readonly IMergeCoordinator _merge; private readonly MergeCoordinator _merge;
// ── Section view models ─────────────────────────────────────────────────── // ── Section view models ───────────────────────────────────────────────────
public AgentConfigEditorViewModel AgentSettings { get; } public AgentConfigEditorViewModel AgentSettings { get; }
@@ -325,7 +325,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
IWorkerClient worker, IWorkerClient worker,
IServiceProvider services, IServiceProvider services,
INotesApi notesApi, INotesApi notesApi,
IMergeCoordinator merge) MergeCoordinator merge)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worker = worker; _worker = worker;
@@ -11,7 +11,7 @@ namespace ClaudeDo.Ui.ViewModels.MissionControl;
/// Claude session, or an ad-hoc/free session in a user-chosen directory (no task, <see cref="TaskId"/> /// Claude session, or an ad-hoc/free session in a user-chosen directory (no task, <see cref="TaskId"/>
/// is null — ad-hoc panes are never deduped, unlike task-based ones). /// is null — ad-hoc panes are never deduped, unlike task-based ones).
/// </summary> /// </summary>
public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControlPane, IDisposable public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
{ {
public string? TaskId { get; } public string? TaskId { get; }
@@ -1,11 +0,0 @@
namespace ClaudeDo.Ui.ViewModels.MissionControl;
/// <summary>
/// Common contract for anything hosted as a pane in the Command Center — currently only
/// <see cref="ConPtyPaneViewModel"/>, kept as its own abstraction so the grid/tabs layout
/// toggle binds a pane collection rather than a concrete ConPTY-specific type.
/// </summary>
public interface IMissionControlPane
{
string DisplayTitle { get; }
}
@@ -35,7 +35,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// Mirror of ConPtySessions typed as the pane abstraction so the layout toggle (grid/tabs) // Mirror of ConPtySessions typed as the pane abstraction so the layout toggle (grid/tabs)
// binds one contract rather than a ConPTY-specific type. // binds one contract rather than a ConPTY-specific type.
public ObservableCollection<IMissionControlPane> Panes { get; } = new(); public ObservableCollection<ConPtyPaneViewModel> Panes { get; } = new();
[ObservableProperty] private int _columnCount = 1; [ObservableProperty] private int _columnCount = 1;
@@ -43,7 +43,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
[NotifyPropertyChangedFor(nameof(LayoutToggleLabel))] [NotifyPropertyChangedFor(nameof(LayoutToggleLabel))]
private bool _isFocusMode; private bool _isFocusMode;
[ObservableProperty] private IMissionControlPane? _focusedPane; [ObservableProperty] private ConPtyPaneViewModel? _focusedPane;
public string LayoutToggleLabel => Loc.T(IsFocusMode ? "missionControl.overviewMode" : "missionControl.focusMode"); public string LayoutToggleLabel => Loc.T(IsFocusMode ? "missionControl.overviewMode" : "missionControl.focusMode");
@@ -403,7 +403,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_ => 3, _ => 3,
}; };
OnPropertyChanged(nameof(HasPanes)); OnPropertyChanged(nameof(HasPanes));
if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems?[0] is IMissionControlPane added) if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems?[0] is ConPtyPaneViewModel added)
FocusedPane = added; FocusedPane = added;
else if (e.Action == NotifyCollectionChangedAction.Remove && ReferenceEquals(FocusedPane, e.OldItems?[0])) else if (e.Action == NotifyCollectionChangedAction.Remove && ReferenceEquals(FocusedPane, e.OldItems?[0]))
FocusedPane = Panes.LastOrDefault(); FocusedPane = Panes.LastOrDefault();
@@ -9,7 +9,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class MergeModalViewModel : ViewModelBase public sealed partial class MergeModalViewModel : ViewModelBase
{ {
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
private readonly IMergeCoordinator _merge; private readonly MergeCoordinator _merge;
public string TaskId { get; set; } = ""; public string TaskId { get; set; } = "";
public string TaskTitle { get; set; } = ""; public string TaskTitle { get; set; } = "";
@@ -42,7 +42,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
/// True once a conflict has been handed off to the resolver — also a cue to close the diff window. /// True once a conflict has been handed off to the resolver — also a cue to close the diff window.
public bool RoutedToResolver { get; private set; } public bool RoutedToResolver { get; private set; }
public MergeModalViewModel(IWorkerClient worker, IMergeCoordinator merge) public MergeModalViewModel(IWorkerClient worker, MergeCoordinator merge)
{ {
_worker = worker; _worker = worker;
_merge = merge; _merge = merge;
@@ -8,7 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class OnlineInboxSettingsViewModel : ViewModelBase public sealed partial class OnlineInboxSettingsViewModel : ViewModelBase
{ {
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
private readonly IOnlineLoginService _loginService; private readonly OnlineLoginService _loginService;
[ObservableProperty] private bool _enabled; [ObservableProperty] private bool _enabled;
[ObservableProperty] private string _apiBaseUrl = ""; [ObservableProperty] private string _apiBaseUrl = "";
@@ -21,7 +21,7 @@ public sealed partial class OnlineInboxSettingsViewModel : ViewModelBase
[ObservableProperty] private bool _isBusy; [ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _statusMessage = ""; [ObservableProperty] private string _statusMessage = "";
public OnlineInboxSettingsViewModel(IWorkerClient worker, IOnlineLoginService loginService) public OnlineInboxSettingsViewModel(IWorkerClient worker, OnlineLoginService loginService)
{ {
_worker = worker; _worker = worker;
_loginService = loginService; _loginService = loginService;
@@ -73,7 +73,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public Action? CloseAction { get; set; } public Action? CloseAction { get; set; }
public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime, public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime,
IOnlineLoginService onlineLoginService, OnlineLoginService onlineLoginService,
ILocalizer localizer, AppSettings appSettings, ILocalizer localizer, AppSettings appSettings,
IDbContextFactory<ClaudeDoDbContext> dbFactory) IDbContextFactory<ClaudeDoDbContext> dbFactory)
{ {
@@ -69,7 +69,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
{ {
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
private readonly Func<DiffViewerViewModel> _diffVmFactory; private readonly Func<DiffViewerViewModel> _diffVmFactory;
private readonly IMergeCoordinator _merge; private readonly MergeCoordinator _merge;
[ObservableProperty] private string? _listIdFilter; [ObservableProperty] private string? _listIdFilter;
[ObservableProperty] private string _title = "Worktrees"; [ObservableProperty] private string _title = "Worktrees";
@@ -104,7 +104,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; } public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; } public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; }
public WorktreesOverviewModalViewModel(IWorkerClient worker, Func<DiffViewerViewModel> diffVmFactory, IMergeCoordinator merge) public WorktreesOverviewModalViewModel(IWorkerClient worker, Func<DiffViewerViewModel> diffVmFactory, MergeCoordinator merge)
{ {
_worker = worker; _worker = worker;
_diffVmFactory = diffVmFactory; _diffVmFactory = diffVmFactory;
@@ -75,7 +75,7 @@
SelectedItem="{Binding FocusedPane}" SelectedItem="{Binding FocusedPane}"
IsVisible="{Binding IsFocusMode}"> IsVisible="{Binding IsFocusMode}">
<TabControl.ItemTemplate> <TabControl.ItemTemplate>
<DataTemplate x:DataType="vmm:IMissionControlPane"> <DataTemplate x:DataType="vmm:ConPtyPaneViewModel">
<TextBlock Text="{Binding DisplayTitle}" TextTrimming="CharacterEllipsis" MaxWidth="160" /> <TextBlock Text="{Binding DisplayTitle}" TextTrimming="CharacterEllipsis" MaxWidth="160" />
</DataTemplate> </DataTemplate>
</TabControl.ItemTemplate> </TabControl.ItemTemplate>
+3 -3
View File
@@ -11,10 +11,10 @@ public sealed record SaveFindingResult(
[McpServerToolType] [McpServerToolType]
public sealed class FindingsMcpTools public sealed class FindingsMcpTools
{ {
private readonly IFindingsStore _store; private readonly FindingsStore _store;
private readonly IFindingsStoreLocator _locator; private readonly FindingsStoreLocator _locator;
public FindingsMcpTools(IFindingsStore store, IFindingsStoreLocator locator) public FindingsMcpTools(FindingsStore store, FindingsStoreLocator locator)
{ {
_store = store; _store = store;
_locator = locator; _locator = locator;
+17 -1
View File
@@ -4,13 +4,29 @@ using ClaudeDo.Worker.Git;
namespace ClaudeDo.Worker.Findings; namespace ClaudeDo.Worker.Findings;
/// <summary>One durable finding, as handed in by a caller. Git and DB lookups happen above this layer.</summary>
public sealed record FindingInput(
string Slug,
string Title,
string Body,
string Scope,
string SourceTaskId,
string VerifiedAgainst);
public sealed record SaveFindingOutcome(
string Slug,
string Path,
bool Created,
int TotalFindings,
bool NearCapacity);
/// <summary> /// <summary>
/// Owns &lt;working-dir&gt;/.claudedo/ — one markdown file per finding plus a rebuilt INDEX.md. /// Owns &lt;working-dir&gt;/.claudedo/ — one markdown file per finding plus a rebuilt INDEX.md.
/// Pure filesystem: the caller supplies the head commit and source task id. /// Pure filesystem: the caller supplies the head commit and source task id.
/// INDEX.md is always regenerated from the files on disk, never patched, so findings the user /// INDEX.md is always regenerated from the files on disk, never patched, so findings the user
/// deleted or renamed by hand disappear from the index on the next write. /// deleted or renamed by hand disappear from the index on the next write.
/// </summary> /// </summary>
public sealed class FindingsStore : IFindingsStore public sealed class FindingsStore
{ {
/// <summary>INDEX.md is read by every run; past this many entries it stops paying for itself.</summary> /// <summary>INDEX.md is read by every run; past this many entries it stops paying for itself.</summary>
public const int WarnThreshold = 80; public const int WarnThreshold = 80;
@@ -3,12 +3,15 @@ using ClaudeDo.Data.Repositories;
namespace ClaudeDo.Worker.Findings; namespace ClaudeDo.Worker.Findings;
/// <summary>The main checkout a finding belongs to, plus whether its store is committed with the repo.</summary>
public sealed record FindingsTarget(string ListId, string ListName, string WorkingDir, bool Tracked);
/// <summary> /// <summary>
/// Maps an MCP call to the project whose findings store it targets. Always resolves to the list's /// Maps an MCP call to the project whose findings store it targets. Always resolves to the list's
/// WorkingDir — the main checkout — even when the caller runs inside a worktree, because concurrent /// WorkingDir — the main checkout — even when the caller runs inside a worktree, because concurrent
/// writes into worktree copies would produce INDEX.md merge conflicts. /// writes into worktree copies would produce INDEX.md merge conflicts.
/// </summary> /// </summary>
public sealed class FindingsStoreLocator : IFindingsStoreLocator public sealed class FindingsStoreLocator
{ {
private readonly TaskRepository _tasks; private readonly TaskRepository _tasks;
private readonly ListRepository _lists; private readonly ListRepository _lists;
@@ -1,22 +0,0 @@
namespace ClaudeDo.Worker.Findings;
/// <summary>One durable finding, as handed in by a caller. Git and DB lookups happen above this layer.</summary>
public sealed record FindingInput(
string Slug,
string Title,
string Body,
string Scope,
string SourceTaskId,
string VerifiedAgainst);
public sealed record SaveFindingOutcome(
string Slug,
string Path,
bool Created,
int TotalFindings,
bool NearCapacity);
public interface IFindingsStore
{
Task<SaveFindingOutcome> SaveAsync(string workingDir, FindingInput input, CancellationToken ct, bool tracked = false);
}
@@ -1,10 +0,0 @@
namespace ClaudeDo.Worker.Findings;
/// <summary>The main checkout a finding belongs to, plus whether its store is committed with the repo.</summary>
public sealed record FindingsTarget(string ListId, string ListName, string WorkingDir, bool Tracked);
public interface IFindingsStoreLocator
{
Task<FindingsTarget> ResolveForTaskAsync(string taskId, CancellationToken ct);
Task<FindingsTarget> ResolveForListAsync(string listIdOrName, CancellationToken ct);
}
+6 -6
View File
@@ -229,11 +229,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly PlanningAggregator _planningAggregator; private readonly PlanningAggregator _planningAggregator;
private readonly PlanningMergeOrchestrator _planningMergeOrchestrator; private readonly PlanningMergeOrchestrator _planningMergeOrchestrator;
private readonly PlanningChainCoordinator _planningChain; private readonly PlanningChainCoordinator _planningChain;
private readonly IPrimeScheduleSignal _primeSignal; private readonly PrimeScheduleSignal _primeSignal;
private readonly IPrimeRunner _primeRunner; private readonly IPrimeRunner _primeRunner;
private readonly ITaskStateService _state; private readonly ITaskStateService _state;
private readonly IWeekReportService _report; private readonly WeekReportService _report;
private readonly IRefineRunner _refineRunner; private readonly RefineRunner _refineRunner;
private readonly WorkerConfig _cfg; private readonly WorkerConfig _cfg;
private readonly OnlineInboxConfig _onlineInboxConfig; private readonly OnlineInboxConfig _onlineInboxConfig;
private readonly OnlineTokenStore _onlineTokenStore; private readonly OnlineTokenStore _onlineTokenStore;
@@ -264,11 +264,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
PlanningAggregator planningAggregator, PlanningAggregator planningAggregator,
PlanningMergeOrchestrator planningMergeOrchestrator, PlanningMergeOrchestrator planningMergeOrchestrator,
PlanningChainCoordinator planningChain, PlanningChainCoordinator planningChain,
IPrimeScheduleSignal primeSignal, PrimeScheduleSignal primeSignal,
IPrimeRunner primeRunner, IPrimeRunner primeRunner,
ITaskStateService state, ITaskStateService state,
IWeekReportService report, WeekReportService report,
IRefineRunner refineRunner, RefineRunner refineRunner,
WorkerConfig cfg, WorkerConfig cfg,
OnlineInboxConfig onlineInboxConfig, OnlineInboxConfig onlineInboxConfig,
OnlineTokenStore onlineTokenStore, OnlineTokenStore onlineTokenStore,
@@ -1,24 +0,0 @@
using ClaudeDo.Worker.Online.Interfaces;
namespace ClaudeDo.Worker.Online;
/// <summary>
/// Simple <see cref="IOnlineAuthProvider"/> that returns a fixed token supplied at construction.
/// Used as the default DI registration until <c>ZitadelAuthProvider</c> is wired (Phase 2).
/// Also serves as the test double.
/// </summary>
public sealed class StaticTokenAuthProvider : IOnlineAuthProvider
{
private readonly string? _token;
public StaticTokenAuthProvider(string? token = null)
{
_token = token;
}
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
=> Task.FromResult(_token);
public Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default)
=> Task.FromResult(_token);
}
@@ -86,7 +86,7 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
return Task.CompletedTask; return Task.CompletedTask;
} }
// The raw claude CLI args for a --resume launch, shared with InteractiveLaunchSpecService // The raw claude CLI args for a --resume launch, shared with IInteractiveLaunchSpecService
// (which needs the bare Exe/Args pair for a ConPTY host, not a wrapped pwsh command line). // (which needs the bare Exe/Args pair for a ConPTY host, not a wrapped pwsh command line).
internal static IReadOnlyList<string> BuildResumeArgs(string claudeSessionId) => internal static IReadOnlyList<string> BuildResumeArgs(string claudeSessionId) =>
new[] { "--resume", claudeSessionId }; new[] { "--resume", claudeSessionId };
@@ -99,7 +99,7 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
BuildPwshCommand(claudePath, BuildPlanningStartArgs(ctx)); BuildPwshCommand(claudePath, BuildPlanningStartArgs(ctx));
// The raw claude CLI args for an interactive planning START, shared with the embedded-ConPTY // The raw claude CLI args for an interactive planning START, shared with the embedded-ConPTY
// planning path (InteractiveLaunchSpecService), which needs the bare Exe/Args pair rather than // planning path (IInteractiveLaunchSpecService), which needs the bare Exe/Args pair rather than
// a pwsh-wrapped command line. Arg order matters: variadic flags (--allowedTools, --add-dir) // a pwsh-wrapped command line. Arg order matters: variadic flags (--allowedTools, --add-dir)
// come first; the single-line kickoff prompt is positional, so it must follow a single-value // come first; the single-line kickoff prompt is positional, so it must follow a single-value
// flag (--append-system-prompt-file) or a variadic flag would swallow it. // flag (--append-system-prompt-file) or a variadic flag would swallow it.
@@ -176,7 +176,7 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
?? throw new TerminalLaunchException("Failed to start Windows Terminal process."); ?? throw new TerminalLaunchException("Failed to start Windows Terminal process.");
} }
// Not private: reused by InteractiveLaunchSpecService to resolve the claude executable // Not private: reused by IInteractiveLaunchSpecService to resolve the claude executable
// for a ConPTY-hosted launch spec the same way a wt-launched terminal resolves it. // for a ConPTY-hosted launch spec the same way a wt-launched terminal resolves it.
internal static string? Resolve(string pathOrName) internal static string? Resolve(string pathOrName)
{ {
@@ -1,6 +0,0 @@
namespace ClaudeDo.Worker.Prime;
public interface IPrimeScheduleSignal
{
void Signal();
CancellationToken CurrentToken { get; }
}
@@ -1,6 +1,6 @@
namespace ClaudeDo.Worker.Prime; namespace ClaudeDo.Worker.Prime;
public sealed class PrimeScheduleSignal : IPrimeScheduleSignal, IDisposable public sealed class PrimeScheduleSignal : IDisposable
{ {
private CancellationTokenSource _cts = new(); private CancellationTokenSource _cts = new();
private readonly object _lock = new(); private readonly object _lock = new();
+2 -2
View File
@@ -21,7 +21,7 @@ public sealed class PrimeScheduler : BackgroundService
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IPrimeRunner _runner; private readonly IPrimeRunner _runner;
private readonly IPrimeClock _clock; private readonly IPrimeClock _clock;
private readonly IPrimeScheduleSignal _signal; private readonly PrimeScheduleSignal _signal;
private readonly IPrimeBroadcaster _broadcaster; private readonly IPrimeBroadcaster _broadcaster;
private readonly PrimeSchedulerOptions _options; private readonly PrimeSchedulerOptions _options;
private readonly ILogger<PrimeScheduler> _logger; private readonly ILogger<PrimeScheduler> _logger;
@@ -30,7 +30,7 @@ public sealed class PrimeScheduler : BackgroundService
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
IPrimeRunner runner, IPrimeRunner runner,
IPrimeClock clock, IPrimeClock clock,
IPrimeScheduleSignal signal, PrimeScheduleSignal signal,
IPrimeBroadcaster broadcaster, IPrimeBroadcaster broadcaster,
PrimeSchedulerOptions options, PrimeSchedulerOptions options,
ILogger<PrimeScheduler> logger) ILogger<PrimeScheduler> logger)
+6 -7
View File
@@ -67,7 +67,7 @@ builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
builder.Services.AddSingleton(cfg); builder.Services.AddSingleton(cfg);
builder.Services.AddSingleton<AttachmentStore>(); builder.Services.AddSingleton<AttachmentStore>();
// Singleton so the always-on and in-task MCP servers share the same index write lock. // Singleton so the always-on and in-task MCP servers share the same index write lock.
builder.Services.AddSingleton<IFindingsStore, FindingsStore>(); builder.Services.AddSingleton<FindingsStore>();
builder.Services.AddHostedService<StaleTaskRecovery>(); builder.Services.AddHostedService<StaleTaskRecovery>();
builder.Services.AddHostedService<OrphanRecovery>(); builder.Services.AddHostedService<OrphanRecovery>();
builder.Services.AddHostedService<AttachmentOrphanRecovery>(); builder.Services.AddHostedService<AttachmentOrphanRecovery>();
@@ -140,7 +140,7 @@ builder.Services.AddSingleton<OverrideSlotService>();
builder.Services.AddSingleton<IClaudeHistoryReader>(_ => builder.Services.AddSingleton<IClaudeHistoryReader>(_ =>
new ClaudeHistoryReader(Path.Combine( new ClaudeHistoryReader(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "projects"))); Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "projects")));
builder.Services.AddSingleton<IWeekReportService, WeekReportService>(); builder.Services.AddSingleton<WeekReportService>();
// Usage // Usage
builder.Services.AddSingleton<ITranscriptUsageReader, TranscriptUsageReader>(); builder.Services.AddSingleton<ITranscriptUsageReader, TranscriptUsageReader>();
@@ -154,14 +154,13 @@ builder.Services.AddSingleton<TokenTrackerService>();
// Prime Claude // Prime Claude
builder.Services.AddSingleton<IPrimeClock, PrimeClock>(); builder.Services.AddSingleton<IPrimeClock, PrimeClock>();
builder.Services.AddSingleton<PrimeScheduleSignal>(); builder.Services.AddSingleton<PrimeScheduleSignal>();
builder.Services.AddSingleton<IPrimeScheduleSignal>(sp => sp.GetRequiredService<PrimeScheduleSignal>());
builder.Services.AddSingleton<IPrimeRunner, PrimeRunner>(); builder.Services.AddSingleton<IPrimeRunner, PrimeRunner>();
builder.Services.AddSingleton(PrimeSchedulerOptions.Default); builder.Services.AddSingleton(PrimeSchedulerOptions.Default);
builder.Services.AddSingleton<IPrimeBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>()); builder.Services.AddSingleton<IPrimeBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
builder.Services.AddHostedService<PrimeScheduler>(); builder.Services.AddHostedService<PrimeScheduler>();
// Refine // Refine
builder.Services.AddSingleton<IRefineRunner, RefineRunner>(); builder.Services.AddSingleton<RefineRunner>();
builder.Services.AddSingleton<IRefineBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>()); builder.Services.AddSingleton<IRefineBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
// "Continue on session limit reset" toggle — depends on UsageState, registered below. // "Continue on session limit reset" toggle — depends on UsageState, registered below.
@@ -199,7 +198,7 @@ builder.Services.AddScoped<ClaudeDoDbContext>(sp =>
builder.Services.AddScoped<TaskRepository>(); builder.Services.AddScoped<TaskRepository>();
builder.Services.AddScoped<ListRepository>(); builder.Services.AddScoped<ListRepository>();
builder.Services.AddScoped<PlanningMcpService>(); builder.Services.AddScoped<PlanningMcpService>();
builder.Services.AddScoped<IFindingsStoreLocator, FindingsStoreLocator>(); builder.Services.AddScoped<FindingsStoreLocator>();
builder.Services.AddScoped<TaskRunFindingsMcpTools>(); builder.Services.AddScoped<TaskRunFindingsMcpTools>();
builder.Services.AddMcpServer() builder.Services.AddMcpServer()
.WithHttpTransport() .WithHttpTransport()
@@ -339,8 +338,8 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AttachmentStore>()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AttachmentStore>());
externalBuilder.Services.AddScoped<TaskAttachmentRepository>(); externalBuilder.Services.AddScoped<TaskAttachmentRepository>();
externalBuilder.Services.AddScoped<AttachmentMcpTools>(); externalBuilder.Services.AddScoped<AttachmentMcpTools>();
externalBuilder.Services.AddSingleton<IFindingsStore>(app.Services.GetRequiredService<IFindingsStore>()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<FindingsStore>());
externalBuilder.Services.AddScoped<IFindingsStoreLocator, FindingsStoreLocator>(); externalBuilder.Services.AddScoped<FindingsStoreLocator>();
externalBuilder.Services.AddScoped<FindingsMcpTools>(); externalBuilder.Services.AddScoped<FindingsMcpTools>();
externalBuilder.Services.AddMcpServer() externalBuilder.Services.AddMcpServer()
.WithHttpTransport() .WithHttpTransport()
@@ -1,8 +0,0 @@
namespace ClaudeDo.Worker.Refine;
public interface IRefineRunner
{
Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct);
}
public sealed record RefineRunOutcome(bool Success, string Message);
+3 -1
View File
@@ -7,7 +7,9 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Refine; namespace ClaudeDo.Worker.Refine;
public sealed class RefineRunner : IRefineRunner public sealed record RefineRunOutcome(bool Success, string Message);
public sealed class RefineRunner
{ {
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5); private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
private const int MaxTurns = 5; private const int MaxTurns = 5;
@@ -1,7 +0,0 @@
namespace ClaudeDo.Worker.Report.Interfaces;
public interface IWeekReportService
{
Task<string?> GetStoredAsync(DateOnly start, DateOnly end, CancellationToken ct = default);
Task<string> GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default);
}
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging;
namespace ClaudeDo.Worker.Report; namespace ClaudeDo.Worker.Report;
public sealed class WeekReportService : IWeekReportService public sealed class WeekReportService
{ {
private static readonly string[] DefaultExcludes = { @"C:\Private" }; private static readonly string[] DefaultExcludes = { @"C:\Private" };
private const string NoActivity = "_No activity in this period._"; private const string NoActivity = "_No activity in this period._";
@@ -11,12 +11,12 @@ public sealed record SaveTaskFindingResult(
[McpServerToolType] [McpServerToolType]
public sealed class TaskRunFindingsMcpTools public sealed class TaskRunFindingsMcpTools
{ {
private readonly IFindingsStore _store; private readonly FindingsStore _store;
private readonly IFindingsStoreLocator _locator; private readonly FindingsStoreLocator _locator;
private readonly TaskRunMcpContextAccessor _ctx; private readonly TaskRunMcpContextAccessor _ctx;
public TaskRunFindingsMcpTools( public TaskRunFindingsMcpTools(
IFindingsStore store, IFindingsStoreLocator locator, TaskRunMcpContextAccessor ctx) FindingsStore store, FindingsStoreLocator locator, TaskRunMcpContextAccessor ctx)
{ {
_store = store; _store = store;
_locator = locator; _locator = locator;
@@ -1,11 +1,11 @@
using ClaudeDo.Data.Filtering.Filters; using ClaudeDo.Data.Filtering;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Tests.Filtering; namespace ClaudeDo.Data.Tests.Filtering;
/// <summary> /// <summary>
/// Proves that <see cref="ClaudeDo.Data.Filtering.ITaskListFilter.MatchExpression"/> is a real /// Proves that <see cref="ClaudeDo.Data.Filtering.TaskListFilter.MatchExpression"/> is a real
/// expression tree that EF Core can translate into SQL, not just an in-memory delegate — the /// expression tree that EF Core can translate into SQL, not just an in-memory delegate — the
/// whole point of splitting it out from <c>Matches</c>. /// whole point of splitting it out from <c>Matches</c>.
/// </summary> /// </summary>
@@ -1,4 +1,4 @@
using ClaudeDo.Data.Filtering.Filters; using ClaudeDo.Data.Filtering;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Tests.Filtering; namespace ClaudeDo.Data.Tests.Filtering;
@@ -1,3 +1,4 @@
using ClaudeDo.Data.Filtering;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -1,5 +1,4 @@
using ClaudeDo.Data.Filtering; using ClaudeDo.Data.Filtering;
using ClaudeDo.Data.Filtering.Filters;
namespace ClaudeDo.Data.Tests.Filtering; namespace ClaudeDo.Data.Tests.Filtering;
@@ -1,4 +1,4 @@
using ClaudeDo.Data.Filtering.Filters; using ClaudeDo.Data.Filtering;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Tests.Filtering; namespace ClaudeDo.Data.Tests.Filtering;
@@ -1,4 +1,4 @@
using ClaudeDo.Data.Filtering.Filters; using ClaudeDo.Data.Filtering;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -0,0 +1,16 @@
using ClaudeDo.Worker.Online.Interfaces;
namespace ClaudeDo.Worker.Online;
/// <summary>
/// Test double: an <see cref="IOnlineAuthProvider"/> that returns a fixed token supplied at
/// construction. Production uses <c>ZitadelAuthProvider</c>.
/// </summary>
public sealed class StaticTokenAuthProvider(string? token = null) : IOnlineAuthProvider
{
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
=> Task.FromResult(token);
public Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default)
=> Task.FromResult(token);
}
@@ -1,30 +0,0 @@
using ClaudeDo.Worker.Online;
namespace ClaudeDo.Worker.Tests.Online;
public sealed class StaticTokenAuthProviderTests
{
[Fact]
public async Task WithToken_Returns_Token()
{
var provider = new StaticTokenAuthProvider("my-token");
var result = await provider.GetAccessTokenAsync();
Assert.Equal("my-token", result);
}
[Fact]
public async Task WithNull_Returns_Null()
{
var provider = new StaticTokenAuthProvider(null);
var result = await provider.GetAccessTokenAsync();
Assert.Null(result);
}
[Fact]
public async Task Default_Returns_Null()
{
var provider = new StaticTokenAuthProvider();
var result = await provider.GetAccessTokenAsync();
Assert.Null(result);
}
}