# 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" ```