feat(worker,ui): wire EF Core into DI and update all consumers to IDbContextFactory

Worker and App Program.cs: replace SqliteConnectionFactory+SchemaInitializer
with AddDbContextFactory<ClaudeDoDbContext> + Database.Migrate(). Repos
changed from AddSingleton to AddScoped.

All singleton services (QueueService, StaleTaskRecovery, WorktreeManager,
TaskRunner) and singleton ViewModels (MainWindowViewModel, TaskDetailViewModel,
TaskListViewModel, TaskEditorViewModel) now take IDbContextFactory<ClaudeDoDbContext>
and create short-lived contexts per operation.

Test infrastructure: DbFixture now uses EF migrations instead of SchemaInitializer;
all test classes create contexts via DbFixture.CreateContext().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-16 08:59:24 +02:00
parent b7be52a623
commit 36484ed45a
18 changed files with 479 additions and 232 deletions

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
@@ -9,18 +10,15 @@ using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskDetailViewModel : ViewModelBase
{
private readonly TaskRepository _taskRepo;
private readonly WorktreeRepository _worktreeRepo;
private readonly ListRepository _listRepo;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly WorkerClient _worker;
private readonly TagRepository _tagRepo;
private readonly SubtaskRepository _subtaskRepo;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string? _description;
@@ -62,17 +60,11 @@ public partial class TaskDetailViewModel : ViewModelBase
public event Action<string>? TaskChanged;
public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo,
ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo,
SubtaskRepository subtaskRepo)
public TaskDetailViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, GitService git, WorkerClient worker)
{
_taskRepo = taskRepo;
_worktreeRepo = worktreeRepo;
_listRepo = listRepo;
_dbFactory = dbFactory;
_git = git;
_worker = worker;
_tagRepo = tagRepo;
_subtaskRepo = subtaskRepo;
worker.TaskMessageEvent += OnTaskMessage;
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
@@ -98,8 +90,24 @@ public partial class TaskDetailViewModel : ViewModelBase
try
{
var task = await _taskRepo.GetByIdAsync(taskId, ct);
if (task is null) return;
TaskEntity? task;
List<TagEntity> tags;
List<SubtaskEntity> subtasks;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
task = await taskRepo.GetByIdAsync(taskId, ct);
if (task is null) return;
ct.ThrowIfCancellationRequested();
tags = await taskRepo.GetTagsAsync(taskId, ct);
ct.ThrowIfCancellationRequested();
var subtaskRepo = new SubtaskRepository(context);
subtasks = await subtaskRepo.GetByTaskIdAsync(taskId, ct);
}
ct.ThrowIfCancellationRequested();
if (AvailableAgents.Count == 0)
@@ -149,14 +157,12 @@ public partial class TaskDetailViewModel : ViewModelBase
}
Tags.Clear();
var tags = await _taskRepo.GetTagsAsync(taskId, ct);
foreach (var tag in tags)
Tags.Add(tag);
// Tear down old subtask subscriptions before replacing them.
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Clear();
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId, ct);
foreach (var s in subtasks)
{
var vm = SubtaskItemViewModel.From(s);
@@ -181,7 +187,9 @@ public partial class TaskDetailViewModel : ViewModelBase
{
if (_isLoading || _taskId is null) return;
var entity = await _taskRepo.GetByIdAsync(_taskId);
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var entity = await taskRepo.GetByIdAsync(_taskId);
if (entity is null) return;
entity.Title = Title;
@@ -196,7 +204,7 @@ public partial class TaskDetailViewModel : ViewModelBase
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
entity.Status = status;
await _taskRepo.UpdateAsync(entity);
await taskRepo.UpdateAsync(entity);
StatusText = entity.Status.ToString().ToLowerInvariant();
TaskChanged?.Invoke(_taskId);
}
@@ -207,11 +215,15 @@ public partial class TaskDetailViewModel : ViewModelBase
var name = NewTagInput.Trim();
if (string.IsNullOrEmpty(name) || _taskId is null) return;
var tagId = await _tagRepo.GetOrCreateAsync(name);
await _taskRepo.AddTagAsync(_taskId, tagId);
using var context = _dbFactory.CreateDbContext();
var tagRepo = new TagRepository(context);
var taskRepo = new TaskRepository(context);
var tagId = await tagRepo.GetOrCreateAsync(name);
await taskRepo.AddTagAsync(_taskId, tagId);
Tags.Clear();
var tags = await _taskRepo.GetTagsAsync(_taskId);
var tags = await taskRepo.GetTagsAsync(_taskId);
foreach (var tag in tags)
Tags.Add(tag);
@@ -223,7 +235,9 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task RemoveTag(TagEntity tag)
{
if (_taskId is null) return;
await _taskRepo.RemoveTagAsync(_taskId, tag.Id);
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
await taskRepo.RemoveTagAsync(_taskId, tag.Id);
Tags.Remove(tag);
TaskChanged?.Invoke(_taskId);
}
@@ -241,7 +255,9 @@ public partial class TaskDetailViewModel : ViewModelBase
OrderNum = Subtasks.Count,
CreatedAt = DateTime.UtcNow,
};
await _subtaskRepo.AddAsync(entity);
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
await subtaskRepo.AddAsync(entity);
var vm = SubtaskItemViewModel.From(entity);
vm.PropertyChanged += OnSubtaskPropertyChanged;
Subtasks.Add(vm);
@@ -251,7 +267,11 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task RemoveSubtask(SubtaskItemViewModel item)
{
if (!string.IsNullOrEmpty(item.Id))
await _subtaskRepo.DeleteAsync(item.Id);
{
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
await subtaskRepo.DeleteAsync(item.Id);
}
item.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Remove(item);
}
@@ -262,7 +282,9 @@ public partial class TaskDetailViewModel : ViewModelBase
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
try
{
await _subtaskRepo.UpdateAsync(new SubtaskEntity
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
await subtaskRepo.UpdateAsync(new SubtaskEntity
{
Id = vm.Id,
TaskId = _taskId ?? "",
@@ -321,7 +343,9 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task LoadWorktreeAsync(string taskId)
{
var wt = await _worktreeRepo.GetByTaskIdAsync(taskId);
using var context = _dbFactory.CreateDbContext();
var wtRepo = new WorktreeRepository(context);
var wt = await wtRepo.GetByTaskIdAsync(taskId);
HasWorktree = wt is not null;
if (wt is not null)
{
@@ -378,14 +402,27 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task MergeIntoMainAsync()
{
if (_taskId is null || _listId is null) return;
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
var list = await _listRepo.GetByIdAsync(_listId);
WorktreeEntity? wt;
ListEntity? list;
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
wt = await wtRepo.GetByTaskIdAsync(_taskId);
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(_listId);
}
if (wt is null || list?.WorkingDir is null) return;
await _git.MergeFfOnlyAsync(list.WorkingDir, wt.BranchName);
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Merged);
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Merged);
}
await LoadWorktreeAsync(_taskId);
}
@@ -393,12 +430,25 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task KeepAsBranchAsync()
{
if (_taskId is null || _listId is null) return;
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
var list = await _listRepo.GetByIdAsync(_listId);
WorktreeEntity? wt;
ListEntity? list;
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
wt = await wtRepo.GetByTaskIdAsync(_taskId);
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(_listId);
}
if (wt is null || list?.WorkingDir is null) return;
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Kept);
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Kept);
}
await LoadWorktreeAsync(_taskId);
}
@@ -406,13 +456,26 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task DiscardAsync()
{
if (_taskId is null || _listId is null) return;
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
var list = await _listRepo.GetByIdAsync(_listId);
WorktreeEntity? wt;
ListEntity? list;
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
wt = await wtRepo.GetByTaskIdAsync(_taskId);
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(_listId);
}
if (wt is null || list?.WorkingDir is null) return;
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Discarded);
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Discarded);
}
await LoadWorktreeAsync(_taskId);
}