diff --git a/docs/superpowers/plans/2026-04-16-efcore-migration.md b/docs/superpowers/plans/2026-04-16-efcore-migration.md new file mode 100644 index 0000000..b5fee35 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-efcore-migration.md @@ -0,0 +1,1722 @@ +# EF Core Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the raw ADO.NET data layer with Entity Framework Core and LINQ queries. + +**Architecture:** Single `ClaudeDoDbContext` in `ClaudeDo.Data` with Fluent API configuration. All 6 repositories rewritten to LINQ. EF Core migrations replace `schema.sql` and `SchemaInitializer`. Atomic queue claim kept as `FromSqlRaw`. + +**Tech Stack:** .NET 8.0, Microsoft.EntityFrameworkCore.Sqlite 8.x, EF Core Migrations + +**Spec:** `docs/superpowers/specs/2026-04-16-efcore-migration-design.md` + +--- + +## File Structure + +### New files +- `src/ClaudeDo.Data/ClaudeDoDbContext.cs` — DbContext with DbSets and `OnModelCreating` +- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — Fluent API for TaskEntity +- `src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs` — Fluent API for ListEntity +- `src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs` — Fluent API for TagEntity +- `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs` — Fluent API for ListConfigEntity +- `src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs` — Fluent API for WorktreeEntity +- `src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs` — Fluent API for TaskRunEntity +- `src/ClaudeDo.Data/Configuration/SubtaskEntityConfiguration.cs` — Fluent API for SubtaskEntity +- `src/ClaudeDo.Data/Migrations/` — Generated by `dotnet ef migrations add` + +### Modified files +- `src/ClaudeDo.Data/ClaudeDo.Data.csproj` — Swap packages +- `src/ClaudeDo.Data/Models/TaskEntity.cs` — Add navigation properties +- `src/ClaudeDo.Data/Models/ListEntity.cs` — Add navigation properties +- `src/ClaudeDo.Data/Models/TagEntity.cs` — Add navigation properties +- `src/ClaudeDo.Data/Models/TaskRunEntity.cs` — Add navigation property +- `src/ClaudeDo.Data/Models/SubtaskEntity.cs` — Add navigation property +- `src/ClaudeDo.Data/Models/WorktreeEntity.cs` — Add navigation property +- `src/ClaudeDo.Data/Models/ListConfigEntity.cs` — Add navigation property +- `src/ClaudeDo.Data/Repositories/TagRepository.cs` — Rewrite to EF Core +- `src/ClaudeDo.Data/Repositories/SubtaskRepository.cs` — Rewrite to EF Core +- `src/ClaudeDo.Data/Repositories/WorktreeRepository.cs` — Rewrite to EF Core +- `src/ClaudeDo.Data/Repositories/ListRepository.cs` — Rewrite to EF Core +- `src/ClaudeDo.Data/Repositories/TaskRunRepository.cs` — Rewrite to EF Core +- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — Rewrite to EF Core +- `src/ClaudeDo.App/Program.cs` — EF Core DI registration +- `src/ClaudeDo.Worker/Program.cs` — EF Core DI registration +- `src/ClaudeDo.Worker/Services/QueueService.cs` — Adapt to scoped repos via IDbContextFactory +- `src/ClaudeDo.Worker/Services/StaleTaskRecovery.cs` — Adapt to scoped repos via IDbContextFactory +- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — Adapt to scoped repos via IDbContextFactory +- `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` — Adapt to scoped repos via IDbContextFactory +- `src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs` — Adapt to IDbContextFactory +- `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs` — Adapt to IDbContextFactory +- `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — Adapt to IDbContextFactory +- `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs` — Adapt to IDbContextFactory +- `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — Swap packages +- `tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs` — EF Core fixture + +### Deleted files +- `src/ClaudeDo.Data/SqliteConnectionFactory.cs` +- `src/ClaudeDo.Data/SchemaInitializer.cs` +- `schema/schema.sql` + +--- + +### Task 1: Update Package References + +**Files:** +- Modify: `src/ClaudeDo.Data/ClaudeDo.Data.csproj` +- Modify: `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` + +- [ ] **Step 1: Update ClaudeDo.Data.csproj** + +Replace package references and remove the embedded resource: + +```xml + + + + net8.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + +``` + +- [ ] **Step 2: Update Worker.Tests.csproj** + +Replace `Microsoft.Data.Sqlite` with EF Core: + +```xml + + + + + + + +``` + +- [ ] **Step 3: Verify packages restore** + +Run: `dotnet restore ClaudeDo.slnx` +Expected: Restore succeeds (build will fail — repositories still reference old types). + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Data/ClaudeDo.Data.csproj tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj +git commit -m "chore(data): swap Microsoft.Data.Sqlite for EF Core packages" +``` + +--- + +### Task 2: Add Navigation Properties to Models + +**Files:** +- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs` +- Modify: `src/ClaudeDo.Data/Models/ListEntity.cs` +- Modify: `src/ClaudeDo.Data/Models/TagEntity.cs` +- Modify: `src/ClaudeDo.Data/Models/WorktreeEntity.cs` +- Modify: `src/ClaudeDo.Data/Models/ListConfigEntity.cs` +- Modify: `src/ClaudeDo.Data/Models/TaskRunEntity.cs` +- Modify: `src/ClaudeDo.Data/Models/SubtaskEntity.cs` + +- [ ] **Step 1: Update TaskEntity.cs** + +Add navigation properties at the end of the class, before the closing brace: + +```csharp +namespace ClaudeDo.Data.Models; + +public enum TaskStatus +{ + Manual, + Queued, + Running, + Done, + Failed, +} + +public sealed class TaskEntity +{ + public required string Id { get; init; } + public required string ListId { get; init; } + public required string Title { get; set; } + public string? Description { get; set; } + public TaskStatus Status { get; set; } = TaskStatus.Manual; + public DateTime? ScheduledFor { get; set; } + public string? Result { get; set; } + public string? LogPath { get; set; } + public required DateTime CreatedAt { get; init; } + public DateTime? StartedAt { get; set; } + public DateTime? FinishedAt { get; set; } + public string CommitType { get; set; } = "chore"; + public string? Model { get; set; } + public string? SystemPrompt { get; set; } + public string? AgentPath { get; set; } + + // Navigation properties + public ListEntity List { get; set; } = null!; + public WorktreeEntity? Worktree { get; set; } + public ICollection Tags { get; set; } = new List(); + public ICollection Runs { get; set; } = new List(); + public ICollection Subtasks { get; set; } = new List(); +} +``` + +- [ ] **Step 2: Update ListEntity.cs** + +```csharp +namespace ClaudeDo.Data.Models; + +public sealed class ListEntity +{ + public required string Id { get; init; } + public required string Name { get; set; } + public required DateTime CreatedAt { get; init; } + public string? WorkingDir { get; set; } + public string DefaultCommitType { get; set; } = "chore"; + + // Navigation properties + public ListConfigEntity? Config { get; set; } + public ICollection Tasks { get; set; } = new List(); + public ICollection Tags { get; set; } = new List(); +} +``` + +- [ ] **Step 3: Update TagEntity.cs** + +```csharp +namespace ClaudeDo.Data.Models; + +public sealed class TagEntity +{ + public long Id { get; init; } + public required string Name { get; set; } + + // Navigation properties + public ICollection Lists { get; set; } = new List(); + public ICollection Tasks { get; set; } = new List(); +} +``` + +- [ ] **Step 4: Update WorktreeEntity.cs** + +```csharp +namespace ClaudeDo.Data.Models; + +public enum WorktreeState +{ + Active, + Merged, + Discarded, + Kept, +} + +public sealed class WorktreeEntity +{ + public required string TaskId { get; init; } + public required string Path { get; set; } + public required string BranchName { get; set; } + public required string BaseCommit { get; set; } + public string? HeadCommit { get; set; } + public string? DiffStat { get; set; } + public WorktreeState State { get; set; } = WorktreeState.Active; + public required DateTime CreatedAt { get; init; } + + // Navigation property + public TaskEntity Task { get; set; } = null!; +} +``` + +- [ ] **Step 5: Update ListConfigEntity.cs** + +```csharp +namespace ClaudeDo.Data.Models; + +public sealed class ListConfigEntity +{ + public required string ListId { get; init; } + public string? Model { get; set; } + public string? SystemPrompt { get; set; } + public string? AgentPath { get; set; } + + // Navigation property + public ListEntity List { get; set; } = null!; +} +``` + +- [ ] **Step 6: Update TaskRunEntity.cs** + +```csharp +namespace ClaudeDo.Data.Models; + +public sealed class TaskRunEntity +{ + public required string Id { get; init; } + public required string TaskId { get; init; } + public required int RunNumber { get; init; } + public string? SessionId { get; set; } + public required bool IsRetry { get; init; } + public required string Prompt { get; init; } + public string? ResultMarkdown { get; set; } + public string? StructuredOutputJson { get; set; } + public string? ErrorMarkdown { get; set; } + public int? ExitCode { get; set; } + public int? TurnCount { get; set; } + public int? TokensIn { get; set; } + public int? TokensOut { get; set; } + public string? LogPath { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? FinishedAt { get; set; } + + // Navigation property + public TaskEntity Task { get; set; } = null!; +} +``` + +- [ ] **Step 7: Update SubtaskEntity.cs** + +```csharp +namespace ClaudeDo.Data.Models; + +public sealed class SubtaskEntity +{ + public required string Id { get; init; } + public required string TaskId { get; init; } + public required string Title { get; set; } + public bool Completed { get; set; } + public int OrderNum { get; set; } + public required DateTime CreatedAt { get; init; } + + // Navigation property + public TaskEntity Task { get; set; } = null!; +} +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/ClaudeDo.Data/Models/ +git commit -m "feat(data): add navigation properties to all entity models" +``` + +--- + +### Task 3: Create ClaudeDoDbContext and Fluent API Configurations + +**Files:** +- Create: `src/ClaudeDo.Data/ClaudeDoDbContext.cs` +- Create: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` +- Create: `src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs` +- Create: `src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs` +- Create: `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs` +- Create: `src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs` +- Create: `src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs` +- Create: `src/ClaudeDo.Data/Configuration/SubtaskEntityConfiguration.cs` + +- [ ] **Step 1: Create ClaudeDoDbContext.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data; + +public class ClaudeDoDbContext : DbContext +{ + public ClaudeDoDbContext(DbContextOptions options) : base(options) { } + + public DbSet Tasks => Set(); + public DbSet Lists => Set(); + public DbSet Tags => Set(); + public DbSet ListConfigs => Set(); + public DbSet Worktrees => Set(); + public DbSet TaskRuns => Set(); + public DbSet Subtasks => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly); + } +} +``` + +- [ ] **Step 2: Create TaskEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Configuration; + +public class TaskEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tasks"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).HasColumnName("id"); + builder.Property(t => t.ListId).HasColumnName("list_id").IsRequired(); + builder.Property(t => t.Title).HasColumnName("title").IsRequired(); + builder.Property(t => t.Description).HasColumnName("description"); + builder.Property(t => t.Status).HasColumnName("status").IsRequired() + .HasConversion( + v => v switch + { + TaskStatus.Manual => "manual", + TaskStatus.Queued => "queued", + TaskStatus.Running => "running", + TaskStatus.Done => "done", + TaskStatus.Failed => "failed", + _ => throw new ArgumentOutOfRangeException() + }, + v => v switch + { + "manual" => TaskStatus.Manual, + "queued" => TaskStatus.Queued, + "running" => TaskStatus.Running, + "done" => TaskStatus.Done, + "failed" => TaskStatus.Failed, + _ => throw new ArgumentOutOfRangeException() + }); + builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for"); + builder.Property(t => t.Result).HasColumnName("result"); + builder.Property(t => t.LogPath).HasColumnName("log_path"); + builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired(); + builder.Property(t => t.StartedAt).HasColumnName("started_at"); + builder.Property(t => t.FinishedAt).HasColumnName("finished_at"); + builder.Property(t => t.CommitType).HasColumnName("commit_type").IsRequired().HasDefaultValue("chore"); + builder.Property(t => t.Model).HasColumnName("model"); + builder.Property(t => t.SystemPrompt).HasColumnName("system_prompt"); + builder.Property(t => t.AgentPath).HasColumnName("agent_path"); + + builder.HasOne(t => t.List) + .WithMany(l => l.Tasks) + .HasForeignKey(t => t.ListId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(t => t.Worktree) + .WithOne(w => w.Task) + .HasForeignKey(w => w.TaskId); + + builder.HasMany(t => t.Tags) + .WithMany(tag => tag.Tasks) + .UsingEntity("task_tags", + l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade), + r => r.HasOne(typeof(TaskEntity)).WithMany().HasForeignKey("task_id").OnDelete(DeleteBehavior.Cascade), + j => + { + j.HasKey("task_id", "tag_id"); + j.ToTable("task_tags"); + }); + + builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id"); + builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status"); + } +} +``` + +- [ ] **Step 3: Create ListEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class ListEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("lists"); + + builder.HasKey(l => l.Id); + builder.Property(l => l.Id).HasColumnName("id"); + builder.Property(l => l.Name).HasColumnName("name").IsRequired(); + builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired(); + builder.Property(l => l.WorkingDir).HasColumnName("working_dir"); + builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore"); + + builder.HasOne(l => l.Config) + .WithOne(c => c.List) + .HasForeignKey(c => c.ListId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(l => l.Tags) + .WithMany(tag => tag.Lists) + .UsingEntity("list_tags", + l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade), + r => r.HasOne(typeof(ListEntity)).WithMany().HasForeignKey("list_id").OnDelete(DeleteBehavior.Cascade), + j => + { + j.HasKey("list_id", "tag_id"); + j.ToTable("list_tags"); + }); + } +} +``` + +- [ ] **Step 4: Create TagEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class TagEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tags"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).HasColumnName("id").ValueGeneratedOnAdd(); + builder.Property(t => t.Name).HasColumnName("name").IsRequired(); + builder.HasIndex(t => t.Name).IsUnique(); + + builder.HasData( + new TagEntity { Id = 1, Name = "agent" }, + new TagEntity { Id = 2, Name = "manual" }); + } +} +``` + +- [ ] **Step 5: Create ListConfigEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class ListConfigEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("list_config"); + + builder.HasKey(c => c.ListId); + builder.Property(c => c.ListId).HasColumnName("list_id"); + builder.Property(c => c.Model).HasColumnName("model"); + builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt"); + builder.Property(c => c.AgentPath).HasColumnName("agent_path"); + } +} +``` + +- [ ] **Step 6: Create WorktreeEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class WorktreeEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("worktrees"); + + builder.HasKey(w => w.TaskId); + builder.Property(w => w.TaskId).HasColumnName("task_id"); + builder.Property(w => w.Path).HasColumnName("path").IsRequired(); + builder.Property(w => w.BranchName).HasColumnName("branch_name").IsRequired(); + builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired(); + builder.Property(w => w.HeadCommit).HasColumnName("head_commit"); + builder.Property(w => w.DiffStat).HasColumnName("diff_stat"); + builder.Property(w => w.State).HasColumnName("state").IsRequired() + .HasDefaultValue(WorktreeState.Active) + .HasConversion( + v => v switch + { + WorktreeState.Active => "active", + WorktreeState.Merged => "merged", + WorktreeState.Discarded => "discarded", + WorktreeState.Kept => "kept", + _ => throw new ArgumentOutOfRangeException() + }, + v => v switch + { + "active" => WorktreeState.Active, + "merged" => WorktreeState.Merged, + "discarded" => WorktreeState.Discarded, + "kept" => WorktreeState.Kept, + _ => throw new ArgumentOutOfRangeException() + }); + builder.Property(w => w.CreatedAt).HasColumnName("created_at").IsRequired(); + } +} +``` + +- [ ] **Step 7: Create TaskRunEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class TaskRunEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("task_runs"); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).HasColumnName("id"); + builder.Property(r => r.TaskId).HasColumnName("task_id").IsRequired(); + builder.Property(r => r.RunNumber).HasColumnName("run_number").IsRequired(); + builder.Property(r => r.SessionId).HasColumnName("session_id"); + builder.Property(r => r.IsRetry).HasColumnName("is_retry").IsRequired().HasDefaultValue(false); + builder.Property(r => r.Prompt).HasColumnName("prompt").IsRequired(); + builder.Property(r => r.ResultMarkdown).HasColumnName("result_markdown"); + builder.Property(r => r.StructuredOutputJson).HasColumnName("structured_output"); + builder.Property(r => r.ErrorMarkdown).HasColumnName("error_markdown"); + builder.Property(r => r.ExitCode).HasColumnName("exit_code"); + builder.Property(r => r.TurnCount).HasColumnName("turn_count"); + builder.Property(r => r.TokensIn).HasColumnName("tokens_in"); + builder.Property(r => r.TokensOut).HasColumnName("tokens_out"); + builder.Property(r => r.LogPath).HasColumnName("log_path"); + builder.Property(r => r.StartedAt).HasColumnName("started_at"); + builder.Property(r => r.FinishedAt).HasColumnName("finished_at"); + + builder.HasOne(r => r.Task) + .WithMany(t => t.Runs) + .HasForeignKey(r => r.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(r => r.TaskId).HasDatabaseName("idx_task_runs_task_id"); + } +} +``` + +- [ ] **Step 8: Create SubtaskEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class SubtaskEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("subtasks"); + + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).HasColumnName("id"); + builder.Property(s => s.TaskId).HasColumnName("task_id").IsRequired(); + builder.Property(s => s.Title).HasColumnName("title").IsRequired(); + builder.Property(s => s.Completed).HasColumnName("completed").IsRequired().HasDefaultValue(false); + builder.Property(s => s.OrderNum).HasColumnName("order_num").IsRequired(); + builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.HasOne(s => s.Task) + .WithMany(t => t.Subtasks) + .HasForeignKey(s => s.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(s => s.TaskId).HasDatabaseName("idx_subtasks_task_id"); + } +} +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/ClaudeDo.Data/ClaudeDoDbContext.cs src/ClaudeDo.Data/Configuration/ +git commit -m "feat(data): add ClaudeDoDbContext with Fluent API configurations" +``` + +--- + +### Task 4: Rewrite TagRepository + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/TagRepository.cs` + +- [ ] **Step 1: Rewrite TagRepository to EF Core** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class TagRepository +{ + private readonly ClaudeDoDbContext _context; + + public TagRepository(ClaudeDoDbContext context) => _context = context; + + public async Task> GetAllAsync(CancellationToken ct = default) + { + return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct); + } + + public async Task GetOrCreateAsync(string name, CancellationToken ct = default) + { + var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct); + if (existing is not null) + return existing.Id; + + var tag = new TagEntity { Name = name }; + _context.Tags.Add(tag); + await _context.SaveChangesAsync(ct); + return tag.Id; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/TagRepository.cs +git commit -m "refactor(data): rewrite TagRepository to EF Core LINQ" +``` + +--- + +### Task 5: Rewrite SubtaskRepository + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/SubtaskRepository.cs` + +- [ ] **Step 1: Rewrite SubtaskRepository to EF Core** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class SubtaskRepository +{ + private readonly ClaudeDoDbContext _context; + + public SubtaskRepository(ClaudeDoDbContext context) => _context = context; + + public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default) + { + _context.Subtasks.Add(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default) + { + return await _context.Subtasks + .Where(s => s.TaskId == taskId) + .OrderBy(s => s.OrderNum) + .ToListAsync(ct); + } + + public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default) + { + _context.Subtasks.Update(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(string subtaskId, CancellationToken ct = default) + { + await _context.Subtasks.Where(s => s.Id == subtaskId).ExecuteDeleteAsync(ct); + } + + public async Task DeleteByTaskIdAsync(string taskId, CancellationToken ct = default) + { + await _context.Subtasks.Where(s => s.TaskId == taskId).ExecuteDeleteAsync(ct); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/SubtaskRepository.cs +git commit -m "refactor(data): rewrite SubtaskRepository to EF Core LINQ" +``` + +--- + +### Task 6: Rewrite WorktreeRepository + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/WorktreeRepository.cs` + +- [ ] **Step 1: Rewrite WorktreeRepository to EF Core** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class WorktreeRepository +{ + private readonly ClaudeDoDbContext _context; + + public WorktreeRepository(ClaudeDoDbContext context) => _context = context; + + public async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default) + { + _context.Worktrees.Add(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task GetByTaskIdAsync(string taskId, CancellationToken ct = default) + { + return await _context.Worktrees.FirstOrDefaultAsync(w => w.TaskId == taskId, ct); + } + + public async Task UpdateHeadAsync(string taskId, string headCommit, string? diffStat, CancellationToken ct = default) + { + await _context.Worktrees + .Where(w => w.TaskId == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(w => w.HeadCommit, headCommit) + .SetProperty(w => w.DiffStat, diffStat), ct); + } + + public async Task SetStateAsync(string taskId, WorktreeState state, CancellationToken ct = default) + { + await _context.Worktrees + .Where(w => w.TaskId == taskId) + .ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), ct); + } + + public async Task DeleteAsync(string taskId, CancellationToken ct = default) + { + await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/WorktreeRepository.cs +git commit -m "refactor(data): rewrite WorktreeRepository to EF Core LINQ" +``` + +--- + +### Task 7: Rewrite ListRepository + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/ListRepository.cs` + +- [ ] **Step 1: Rewrite ListRepository to EF Core** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class ListRepository +{ + private readonly ClaudeDoDbContext _context; + + public ListRepository(ClaudeDoDbContext context) => _context = context; + + public async Task AddAsync(ListEntity entity, CancellationToken ct = default) + { + _context.Lists.Add(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default) + { + _context.Lists.Update(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(string listId, CancellationToken ct = default) + { + await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct); + } + + public async Task GetByIdAsync(string listId, CancellationToken ct = default) + { + return await _context.Lists.FirstOrDefaultAsync(l => l.Id == listId, ct); + } + + public async Task> GetAllAsync(CancellationToken ct = default) + { + return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct); + } + + // Tag management via junction table + + public async Task> GetTagsAsync(string listId, CancellationToken ct = default) + { + return await _context.Lists + .Where(l => l.Id == listId) + .SelectMany(l => l.Tags) + .ToListAsync(ct); + } + + public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default) + { + var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct); + var tag = await _context.Tags.FindAsync([tagId], ct); + if (tag is not null && !list.Tags.Any(t => t.Id == tagId)) + { + list.Tags.Add(tag); + await _context.SaveChangesAsync(ct); + } + } + + public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default) + { + var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct); + var tag = list.Tags.FirstOrDefault(t => t.Id == tagId); + if (tag is not null) + { + list.Tags.Remove(tag); + await _context.SaveChangesAsync(ct); + } + } + + // Config management (1:1 with list) + + public async Task GetConfigAsync(string listId, CancellationToken ct = default) + { + return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct); + } + + public async Task UpsertConfigAsync(ListConfigEntity config, CancellationToken ct = default) + { + var existing = await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == config.ListId, ct); + if (existing is null) + { + _context.ListConfigs.Add(config); + } + else + { + existing.Model = config.Model; + existing.SystemPrompt = config.SystemPrompt; + existing.AgentPath = config.AgentPath; + } + await _context.SaveChangesAsync(ct); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/ListRepository.cs +git commit -m "refactor(data): rewrite ListRepository to EF Core LINQ" +``` + +--- + +### Task 8: Rewrite TaskRunRepository + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/TaskRunRepository.cs` + +- [ ] **Step 1: Rewrite TaskRunRepository to EF Core** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class TaskRunRepository +{ + private readonly ClaudeDoDbContext _context; + + public TaskRunRepository(ClaudeDoDbContext context) => _context = context; + + public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default) + { + _context.TaskRuns.Add(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default) + { + _context.TaskRuns.Update(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task GetByIdAsync(string id, CancellationToken ct = default) + { + return await _context.TaskRuns.FirstOrDefaultAsync(r => r.Id == id, ct); + } + + public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default) + { + return await _context.TaskRuns + .Where(r => r.TaskId == taskId) + .OrderBy(r => r.RunNumber) + .ToListAsync(ct); + } + + public async Task GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default) + { + return await _context.TaskRuns + .Where(r => r.TaskId == taskId) + .OrderByDescending(r => r.RunNumber) + .FirstOrDefaultAsync(ct); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/TaskRunRepository.cs +git commit -m "refactor(data): rewrite TaskRunRepository to EF Core LINQ" +``` + +--- + +### Task 9: Rewrite TaskRepository + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` + +This is the most complex repository. The atomic queue claim stays as raw SQL. + +- [ ] **Step 1: Rewrite TaskRepository to EF Core** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Repositories; + +public sealed class TaskRepository +{ + private readonly ClaudeDoDbContext _context; + + public TaskRepository(ClaudeDoDbContext context) => _context = context; + + #region CRUD + + public async Task AddAsync(TaskEntity entity, CancellationToken ct = default) + { + _context.Tasks.Add(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default) + { + _context.Tasks.Update(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(string taskId, CancellationToken ct = default) + { + await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct); + } + + public async Task GetByIdAsync(string taskId, CancellationToken ct = default) + { + return await _context.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, ct); + } + + public async Task> GetByListIdAsync(string listId, CancellationToken ct = default) + { + return await _context.Tasks + .Where(t => t.ListId == listId) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(ct); + } + + #endregion + + #region Status transitions + + public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default) + { + await _context.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Running) + .SetProperty(t => t.StartedAt, startedAt), ct); + } + + public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default) + { + await _context.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Done) + .SetProperty(t => t.FinishedAt, finishedAt) + .SetProperty(t => t.Result, result), ct); + } + + public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default) + { + await _context.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Failed) + .SetProperty(t => t.FinishedAt, finishedAt) + .SetProperty(t => t.Result, result), ct); + } + + public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default) + { + await _context.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.LogPath, logPath), ct); + } + + public async Task FlipAllRunningToFailedAsync(CancellationToken ct = default) + { + await _context.Tasks + .Where(t => t.Status == TaskStatus.Running) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Failed) + .SetProperty(t => t.FinishedAt, DateTime.UtcNow), ct); + } + + #endregion + + #region Tags + + public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default) + { + var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct); + var tag = await _context.Tags.FindAsync([tagId], ct); + if (tag is not null && !task.Tags.Any(t => t.Id == tagId)) + { + task.Tags.Add(tag); + await _context.SaveChangesAsync(ct); + } + } + + public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default) + { + var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct); + var tag = task.Tags.FirstOrDefault(t => t.Id == tagId); + if (tag is not null) + { + task.Tags.Remove(tag); + await _context.SaveChangesAsync(ct); + } + } + + public async Task> GetTagsAsync(string taskId, CancellationToken ct = default) + { + return await _context.Tasks + .Where(t => t.Id == taskId) + .SelectMany(t => t.Tags) + .ToListAsync(ct); + } + + public async Task> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default) + { + var taskTags = _context.Tasks + .Where(t => t.Id == taskId) + .SelectMany(t => t.Tags); + var listTags = _context.Tasks + .Where(t => t.Id == taskId) + .SelectMany(t => t.List.Tags); + return await taskTags.Union(listTags).Distinct().ToListAsync(ct); + } + + #endregion + + #region Queue selection + + public async Task GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default) + { + // Atomic queue claim: UPDATE + SELECT in one statement to prevent TOCTOU races. + // Not expressible in LINQ — kept as raw SQL. + var result = await _context.Tasks.FromSqlRaw(""" + UPDATE tasks SET status = 'running' + WHERE id = ( + SELECT t.id FROM tasks t + JOIN task_tags tt ON tt.task_id = t.id + JOIN tags tg ON tg.id = tt.tag_id AND tg.name = 'agent' + WHERE t.status = 'queued' + AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0}) + ORDER BY t.created_at + LIMIT 1 + ) + RETURNING * + """, now.ToString("o")).ToListAsync(ct); + + return result.FirstOrDefault(); + } + + #endregion +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/TaskRepository.cs +git commit -m "refactor(data): rewrite TaskRepository to EF Core LINQ with raw SQL queue claim" +``` + +--- + +### Task 10: Delete Old Infrastructure and Schema + +**Files:** +- Delete: `src/ClaudeDo.Data/SqliteConnectionFactory.cs` +- Delete: `src/ClaudeDo.Data/SchemaInitializer.cs` +- Delete: `schema/schema.sql` + +- [ ] **Step 1: Delete SqliteConnectionFactory.cs** + +```bash +rm src/ClaudeDo.Data/SqliteConnectionFactory.cs +``` + +- [ ] **Step 2: Delete SchemaInitializer.cs** + +```bash +rm src/ClaudeDo.Data/SchemaInitializer.cs +``` + +- [ ] **Step 3: Delete schema.sql** + +```bash +rm schema/schema.sql +``` + +- [ ] **Step 4: Commit** + +```bash +git add -u src/ClaudeDo.Data/SqliteConnectionFactory.cs src/ClaudeDo.Data/SchemaInitializer.cs schema/schema.sql +git commit -m "chore(data): remove raw ADO.NET infrastructure and schema.sql" +``` + +--- + +### Task 11: Update DI Registration — Worker + +**Files:** +- Modify: `src/ClaudeDo.Worker/Program.cs` + +- [ ] **Step 1: Rewrite Worker Program.cs DI section** + +Replace lines 1-28 with EF Core registration. The `SqliteConnectionFactory` and `SchemaInitializer` calls are replaced by `AddDbContext` and `Database.Migrate()`. + +The full updated `Program.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Services; +using Microsoft.EntityFrameworkCore; + +var cfg = WorkerConfig.Load(); + +var builder = WebApplication.CreateBuilder(args); + +// When launched by the Windows SCM, speak the Service Control Protocol so SCM +// doesn't think we crashed (~30s timeout). No-op when running interactively. +builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker"); + +builder.Services.AddSingleton(cfg); + +// EF Core DbContext — scoped lifetime (one context per scope/request). +builder.Services.AddDbContext(opt => + opt.UseSqlite($"Data Source={cfg.DbPath}")); + +// Repositories — scoped (they depend on the scoped DbContext). +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddHostedService(); +builder.Services.AddSignalR(); + +// Runner stack. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(new AgentFileService(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".todo-app", "agents"))); + +// QueueService: singleton + hosted service (same instance). +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +// Loopback-only bind. Firewall is irrelevant for 127.0.0.1. +builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}"); + +var app = builder.Build(); + +// Apply EF Core migrations at startup. +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); +} + +app.MapHub("/hub"); + +app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})", + cfg.SignalRPort, cfg.DbPath); + +app.Run(); +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Worker/Program.cs +git commit -m "refactor(worker): switch DI from raw ADO.NET to EF Core" +``` + +--- + +### Task 12: Update DI Registration — App + +**Files:** +- Modify: `src/ClaudeDo.App/Program.cs` + +- [ ] **Step 1: Rewrite App Program.cs DI section** + +The App uses `ServiceCollection` directly (no WebApplication builder). ViewModels are singletons that need database access — they take `IDbContextFactory` instead of repositories. + +```csharp +using Avalonia; +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Ui; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System; + +namespace ClaudeDo.App; + +sealed class Program +{ + [STAThread] + public static void Main(string[] args) + { + var services = BuildServices(); + App.Services = services; + + // Apply EF Core migrations at startup. + using (var scope = services.CreateScope()) + { + scope.ServiceProvider.GetRequiredService().Database.Migrate(); + } + + try + { + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } + finally + { + // Dispose the container so WorkerClient.DisposeAsync runs — + // cancels the retry loop and closes the SignalR connection cleanly + // instead of abandoning it. + try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); } + catch { /* best effort on shutdown */ } + } + } + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() +#if DEBUG + .WithDeveloperTools() +#endif + .WithInterFont() + .LogToTrace(); + + private static ServiceProvider BuildServices() + { + var settings = AppSettings.Load(); + var dbPath = Paths.Expand(settings.DbPath); + + var sc = new ServiceCollection(); + + // Infrastructure + sc.AddSingleton(settings); + + // EF Core — register both the factory (for singletons) and scoped context. + sc.AddDbContextFactory(opt => + opt.UseSqlite($"Data Source={dbPath}")); + sc.AddScoped(sp => + sp.GetRequiredService>().CreateDbContext()); + + // Repositories — scoped. + sc.AddScoped(); + sc.AddScoped(); + sc.AddScoped(); + sc.AddScoped(); + sc.AddScoped(); + + // Services + sc.AddSingleton(); + sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService().SignalRUrl)); + + // ViewModels — singletons that use IDbContextFactory for on-demand DB access. + sc.AddTransient(); + sc.AddTransient(); + sc.AddSingleton(); + sc.AddSingleton>(sp => + sp.GetRequiredService>()); + + // ViewModel factory registrations — these need to be updated in Task 13 + // to take IDbContextFactory instead of repositories directly. + // For now, leave them unchanged — they won't compile until Task 13. + + return sc.BuildServiceProvider(); + } +} +``` + +Note: This file will NOT compile until Task 13 updates the ViewModels. That is expected. + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.App/Program.cs +git commit -m "refactor(app): switch DI from raw ADO.NET to EF Core" +``` + +--- + +### Task 13: Update ViewModel and Worker Service Consumers + +All singleton ViewModels and Worker services currently take repository instances directly. Since repositories are now scoped, these consumers must take `IDbContextFactory` and create a fresh context + repositories per operation. + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs` +- Modify: `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs` +- Modify: `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` +- Modify: `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs` +- Modify: `src/ClaudeDo.Worker/Services/QueueService.cs` +- Modify: `src/ClaudeDo.Worker/Services/StaleTaskRecovery.cs` +- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` +- Modify: `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` + +- [ ] **Step 1: Update each consumer** + +For each consumer, apply this pattern: + +**Before (example — QueueService):** +```csharp +private readonly TaskRepository _taskRepo; +public QueueService(TaskRepository taskRepo, ...) { _taskRepo = taskRepo; ... } + +// In method: +var task = await _taskRepo.GetNextQueuedAgentTaskAsync(now, ct); +``` + +**After:** +```csharp +private readonly IDbContextFactory _dbFactory; +public QueueService(IDbContextFactory dbFactory, ...) { _dbFactory = dbFactory; ... } + +// In method: +using var context = _dbFactory.CreateDbContext(); +var taskRepo = new TaskRepository(context); +var task = await taskRepo.GetNextQueuedAgentTaskAsync(now, ct); +``` + +Apply this to every file listed above. Each method that touches the DB creates a context and the repositories it needs. The context is disposed at method end. + +**Consumer → repositories used:** + +| Consumer | Repositories | +|---|---| +| `MainWindowViewModel` | `ListRepository` | +| `TaskDetailViewModel` | `TaskRepository`, `WorktreeRepository`, `ListRepository`, `TagRepository`, `SubtaskRepository` | +| `TaskListViewModel` | `TaskRepository`, `TagRepository`, `ListRepository` | +| `TaskEditorViewModel` | `SubtaskRepository` | +| `QueueService` | `TaskRepository` | +| `StaleTaskRecovery` | `TaskRepository` | +| `TaskRunner` | `TaskRepository`, `TaskRunRepository`, `ListRepository`, `WorktreeRepository`, `SubtaskRepository` | +| `WorktreeManager` | `WorktreeRepository` | + +For each file: +1. Replace repository fields with a single `IDbContextFactory _dbFactory` field. +2. Update constructor to take `IDbContextFactory`. +3. In each method that uses repos: `using var context = _dbFactory.CreateDbContext();` then instantiate needed repos. + +- [ ] **Step 2: Update App Program.cs ViewModel registrations** + +Now that ViewModels take `IDbContextFactory` instead of repos, simplify the registrations. The long factory lambdas that manually resolved repos can be replaced with simpler registrations since ViewModels now only need `IDbContextFactory` and non-repo services: + +```csharp + // ViewModels + sc.AddTransient(); + sc.AddTransient(); + sc.AddSingleton(); + sc.AddSingleton(); + sc.AddSingleton(); + sc.AddSingleton(); +``` + +If the constructors still take non-repo dependencies (GitService, WorkerClient, StatusBarViewModel, etc.), keep those in the constructor and let DI resolve them naturally. + +- [ ] **Step 3: Update Worker Program.cs singleton services** + +Worker singleton services (QueueService, TaskRunner, WorktreeManager) now take `IDbContextFactory` instead of repos. Add factory registration and update service registrations: + +Add to Worker Program.cs after `AddDbContext`: +```csharp +builder.Services.AddDbContextFactory(opt => + opt.UseSqlite($"Data Source={cfg.DbPath}")); +``` + +Singleton services will resolve `IDbContextFactory` from DI and create scoped contexts per operation. + +- [ ] **Step 4: Verify the solution builds** + +Run: `dotnet build ClaudeDo.slnx` +Expected: Build succeeds with zero errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/ src/ClaudeDo.Worker/Services/ src/ClaudeDo.Worker/Runner/ src/ClaudeDo.App/Program.cs src/ClaudeDo.Worker/Program.cs +git commit -m "refactor(ui,worker): switch consumers from direct repos to IDbContextFactory" +``` + +--- + +### Task 14: Generate Initial EF Core Migration + +**Files:** +- Create: `src/ClaudeDo.Data/Migrations/` (generated) + +- [ ] **Step 1: Install EF Core tools if needed** + +Run: `dotnet tool list -g | grep dotnet-ef || dotnet tool install --global dotnet-ef --version 8.0.11` + +- [ ] **Step 2: Generate the initial migration** + +Run from the repo root: + +```bash +dotnet ef migrations add InitialCreate --project src/ClaudeDo.Data --startup-project src/ClaudeDo.Worker +``` + +Expected: Creates `src/ClaudeDo.Data/Migrations/` with `*_InitialCreate.cs` and `ClaudeDoDbContextModelSnapshot.cs`. + +- [ ] **Step 3: Add existing-database compatibility shim** + +Edit the generated `*_InitialCreate.cs` `Up` method. Wrap all `CreateTable` calls in a check for existing tables. Add this at the very start of `Up`: + +```csharp +protected override void Up(MigrationBuilder migrationBuilder) +{ + // If upgrading from the old schema.sql-based setup, tables already exist. + // Check for the 'lists' table as a sentinel — if it exists, skip DDL and + // just ensure the migration is recorded. + migrationBuilder.Sql(""" + CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( + "MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY, + "ProductVersion" TEXT NOT NULL + ); + """); + + // The rest of the migration uses IF NOT EXISTS for safety. + // ... (keep the generated code but add IF NOT EXISTS to each CreateTable) +} +``` + +Alternatively, since SQLite `CREATE TABLE IF NOT EXISTS` is supported, prefix each generated `CREATE TABLE` statement. The simplest approach: in `Up`, replace each `migrationBuilder.CreateTable(...)` with equivalent `migrationBuilder.Sql("CREATE TABLE IF NOT EXISTS ...")` calls. This is a one-time adaptation. + +- [ ] **Step 4: Verify migration applies to a fresh database** + +```bash +rm -f /tmp/test_migration.db +dotnet ef database update --project src/ClaudeDo.Data --startup-project src/ClaudeDo.Worker -- --DbPath /tmp/test_migration.db +``` + +Or simply run the Worker pointed at a new DB path and check it starts. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/Migrations/ +git commit -m "feat(data): add InitialCreate EF Core migration with existing-DB compat shim" +``` + +--- + +### Task 15: Update Test Infrastructure + +**Files:** +- Modify: `tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs` (and any other test files that use DbFixture) + +- [ ] **Step 1: Rewrite DbFixture to use EF Core** + +```csharp +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.Tests.Infrastructure; + +public sealed class DbFixture : IDisposable +{ + public string DbPath { get; } + public DbContextOptions Options { get; } + + public DbFixture() + { + DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db"); + Options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={DbPath}") + .Options; + + using var context = CreateContext(); + context.Database.Migrate(); + } + + public ClaudeDoDbContext CreateContext() => new(Options); + + public void Dispose() + { + try { File.Delete(DbPath); } catch { /* best effort */ } + try { File.Delete(DbPath + "-wal"); } catch { } + try { File.Delete(DbPath + "-shm"); } catch { } + } +} +``` + +- [ ] **Step 2: Update WorktreeManagerTests and other test files** + +Replace `new ListRepository(db.Factory)` with `new ListRepository(db.CreateContext())` etc. For example in WorktreeManagerTests `CreateManagerAsync`: + +```csharp +private async Task<(WorktreeManager mgr, WorktreeRepository wtRepo)> CreateManagerAsync( + TaskEntity task, ListEntity list, string strategy = "sibling", string? centralRoot = null) +{ + var db = new DbFixture(); + _dbFixtures.Add(db); + + var context = db.CreateContext(); + var listRepo = new ListRepository(context); + var taskRepo = new TaskRepository(context); + var wtRepo = new WorktreeRepository(context); + // ... seed and create manager as before, but using context-based repos +``` + +Apply the same pattern to all test files that use `DbFixture.Factory` to construct repositories. + +- [ ] **Step 3: Run tests** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests` +Expected: All existing tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/ +git commit -m "refactor(tests): switch test infrastructure to EF Core DbContext" +``` + +--- + +### Task 16: Full Build and Test Verification + +- [ ] **Step 1: Clean build** + +Run: `dotnet build ClaudeDo.slnx --no-incremental` +Expected: Build succeeds with zero errors. + +- [ ] **Step 2: Run all tests** + +Run: `dotnet test ClaudeDo.slnx` +Expected: All tests pass. + +- [ ] **Step 3: Smoke test — start Worker** + +Run the Worker and verify it starts, creates/migrates the DB, and logs the listening message. + +- [ ] **Step 4: Smoke test — start App** + +Run the App and verify it starts, connects to Worker (if running), and the UI loads. + +- [ ] **Step 5: Verify no references to old types remain** + +```bash +grep -rn "SqliteConnectionFactory\|SchemaInitializer\|Microsoft\.Data\.Sqlite\|using Microsoft\.Data\.Sqlite" src/ tests/ --include="*.cs" --include="*.csproj" +``` + +Expected: Zero matches. + +- [ ] **Step 6: Final commit if any cleanup was needed** + +```bash +git add -A +git commit -m "chore(data): final cleanup after EF Core migration" +``` + +--- + +### Task 17: Update Documentation + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `src/ClaudeDo.Data/CLAUDE.md` + +- [ ] **Step 1: Update project CLAUDE.md** + +Update the Tech Stack, Conventions, and Key Paths sections to reflect EF Core: +- Replace "SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM" with "SQLite (WAL mode) via Entity Framework Core + Microsoft.EntityFrameworkCore.Sqlite" +- Remove references to `schema/schema.sql`, `SqliteConnectionFactory`, `SchemaInitializer` +- Update repository description to mention LINQ instead of raw SQL +- Add note about `IDbContextFactory` pattern for singleton consumers + +- [ ] **Step 2: Update ClaudeDo.Data/CLAUDE.md** + +Rewrite the Infrastructure and Repositories sections: +- Infrastructure: `ClaudeDoDbContext` replaces `SqliteConnectionFactory` + `SchemaInitializer` +- Repositories: "All repositories use EF Core LINQ. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim." +- Remove Conventions about `ToDb()`/`FromDb()` and `DBNull.Value` +- Add Conventions about `IEntityTypeConfiguration` in `Configuration/` folder + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md src/ClaudeDo.Data/CLAUDE.md +git commit -m "docs: update CLAUDE.md files for EF Core migration" +```