Files
ClaudeDo/docs/superpowers/plans/2026-04-16-efcore-migration.md
mika kuns 9236ca6d45 docs(data): add EF Core migration implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:32:18 +02:00

58 KiB

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:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>

</Project>
  • Step 2: Update Worker.Tests.csproj

Replace Microsoft.Data.Sqlite with EF Core:

  <ItemGroup>
    <PackageReference Include="coverlet.collector" Version="6.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
    <PackageReference Include="xunit" Version="2.5.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
  </ItemGroup>
  • Step 3: Verify packages restore

Run: dotnet restore ClaudeDo.slnx Expected: Restore succeeds (build will fail — repositories still reference old types).

  • Step 4: Commit
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:

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<TagEntity> Tags { get; set; } = new List<TagEntity>();
    public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
    public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
}
  • Step 2: Update ListEntity.cs
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<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
    public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
}
  • Step 3: Update TagEntity.cs
namespace ClaudeDo.Data.Models;

public sealed class TagEntity
{
    public long Id { get; init; }
    public required string Name { get; set; }

    // Navigation properties
    public ICollection<ListEntity> Lists { get; set; } = new List<ListEntity>();
    public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
}
  • Step 4: Update WorktreeEntity.cs
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
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
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
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
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

using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;

namespace ClaudeDo.Data;

public class ClaudeDoDbContext : DbContext
{
    public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }

    public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
    public DbSet<ListEntity> Lists => Set<ListEntity>();
    public DbSet<TagEntity> Tags => Set<TagEntity>();
    public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
    public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
    public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
    public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly);
    }
}
  • Step 2: Create TaskEntityConfiguration.cs
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<TaskEntity>
{
    public void Configure(EntityTypeBuilder<TaskEntity> 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<WorktreeEntity>(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
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace ClaudeDo.Data.Configuration;

public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
{
    public void Configure(EntityTypeBuilder<ListEntity> 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<ListConfigEntity>(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
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace ClaudeDo.Data.Configuration;

public class TagEntityConfiguration : IEntityTypeConfiguration<TagEntity>
{
    public void Configure(EntityTypeBuilder<TagEntity> 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
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace ClaudeDo.Data.Configuration;

public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfigEntity>
{
    public void Configure(EntityTypeBuilder<ListConfigEntity> 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
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace ClaudeDo.Data.Configuration;

public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEntity>
{
    public void Configure(EntityTypeBuilder<WorktreeEntity> 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
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace ClaudeDo.Data.Configuration;

public class TaskRunEntityConfiguration : IEntityTypeConfiguration<TaskRunEntity>
{
    public void Configure(EntityTypeBuilder<TaskRunEntity> 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
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace ClaudeDo.Data.Configuration;

public class SubtaskEntityConfiguration : IEntityTypeConfiguration<SubtaskEntity>
{
    public void Configure(EntityTypeBuilder<SubtaskEntity> 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
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

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<List<TagEntity>> GetAllAsync(CancellationToken ct = default)
    {
        return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct);
    }

    public async Task<long> 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
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

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<List<SubtaskEntity>> 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
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

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<WorktreeEntity?> 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
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

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<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)
    {
        return await _context.Lists.FirstOrDefaultAsync(l => l.Id == listId, ct);
    }

    public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default)
    {
        return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct);
    }

    // Tag management via junction table

    public async Task<List<TagEntity>> 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<ListConfigEntity?> 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
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

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<TaskRunEntity?> GetByIdAsync(string id, CancellationToken ct = default)
    {
        return await _context.TaskRuns.FirstOrDefaultAsync(r => r.Id == id, ct);
    }

    public async Task<List<TaskRunEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
    {
        return await _context.TaskRuns
            .Where(r => r.TaskId == taskId)
            .OrderBy(r => r.RunNumber)
            .ToListAsync(ct);
    }

    public async Task<TaskRunEntity?> GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default)
    {
        return await _context.TaskRuns
            .Where(r => r.TaskId == taskId)
            .OrderByDescending(r => r.RunNumber)
            .FirstOrDefaultAsync(ct);
    }
}
  • Step 2: Commit
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
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<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
    {
        return await _context.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, ct);
    }

    public async Task<List<TaskEntity>> 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<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
    {
        return await _context.Tasks
            .Where(t => t.Id == taskId)
            .SelectMany(t => t.Tags)
            .ToListAsync(ct);
    }

    public async Task<List<TagEntity>> 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<TaskEntity?> 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
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

rm src/ClaudeDo.Data/SqliteConnectionFactory.cs
  • Step 2: Delete SchemaInitializer.cs
rm src/ClaudeDo.Data/SchemaInitializer.cs
  • Step 3: Delete schema.sql
rm schema/schema.sql
  • Step 4: Commit
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:

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<ClaudeDoDbContext>(opt =>
    opt.UseSqlite($"Data Source={cfg.DbPath}"));

// Repositories — scoped (they depend on the scoped DbContext).
builder.Services.AddScoped<TagRepository>();
builder.Services.AddScoped<ListRepository>();
builder.Services.AddScoped<TaskRepository>();
builder.Services.AddScoped<SubtaskRepository>();
builder.Services.AddScoped<WorktreeRepository>();
builder.Services.AddScoped<TaskRunRepository>();

builder.Services.AddHostedService<StaleTaskRecovery>();
builder.Services.AddSignalR();

// Runner stack.
builder.Services.AddSingleton<IClaudeProcess, ClaudeProcess>();
builder.Services.AddSingleton<HubBroadcaster>();
builder.Services.AddSingleton<GitService>();
builder.Services.AddSingleton<WorktreeManager>();
builder.Services.AddSingleton<ClaudeArgsBuilder>();
builder.Services.AddSingleton(new AgentFileService(Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
    ".todo-app", "agents")));

// QueueService: singleton + hosted service (same instance).
builder.Services.AddSingleton<QueueService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());

// 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<ClaudeDoDbContext>();
    db.Database.Migrate();
}

app.MapHub<WorkerHub>("/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
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<ClaudeDoDbContext> instead of repositories.

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<ClaudeDoDbContext>().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<App>()
            .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<ClaudeDoDbContext>(opt =>
            opt.UseSqlite($"Data Source={dbPath}"));
        sc.AddScoped<ClaudeDoDbContext>(sp =>
            sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());

        // Repositories — scoped.
        sc.AddScoped<ListRepository>();
        sc.AddScoped<TaskRepository>();
        sc.AddScoped<SubtaskRepository>();
        sc.AddScoped<TagRepository>();
        sc.AddScoped<WorktreeRepository>();

        // Services
        sc.AddSingleton<GitService>();
        sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));

        // ViewModels — singletons that use IDbContextFactory for on-demand DB access.
        sc.AddTransient<ListEditorViewModel>();
        sc.AddTransient<TaskEditorViewModel>();
        sc.AddSingleton<StatusBarViewModel>();
        sc.AddSingleton<IDbContextFactory<ClaudeDoDbContext>>(sp =>
            sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>());

        // 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
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<ClaudeDoDbContext> 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):

private readonly TaskRepository _taskRepo;
public QueueService(TaskRepository taskRepo, ...) { _taskRepo = taskRepo; ... }

// In method:
var task = await _taskRepo.GetNextQueuedAgentTaskAsync(now, ct);

After:

private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public QueueService(IDbContextFactory<ClaudeDoDbContext> 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<ClaudeDoDbContext> _dbFactory field.
  2. Update constructor to take IDbContextFactory<ClaudeDoDbContext>.
  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:

        // ViewModels
        sc.AddTransient<ListEditorViewModel>();
        sc.AddTransient<TaskEditorViewModel>();
        sc.AddSingleton<StatusBarViewModel>();
        sc.AddSingleton<TaskDetailViewModel>();
        sc.AddSingleton<TaskListViewModel>();
        sc.AddSingleton<MainWindowViewModel>();

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:

builder.Services.AddDbContextFactory<ClaudeDoDbContext>(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
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:

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:

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
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
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

using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;

namespace ClaudeDo.Worker.Tests.Infrastructure;

public sealed class DbFixture : IDisposable
{
    public string DbPath { get; }
    public DbContextOptions<ClaudeDoDbContext> Options { get; }

    public DbFixture()
    {
        DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db");
        Options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
            .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:

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
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
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
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<T> in Configuration/ folder

  • Step 3: Commit

git add CLAUDE.md src/ClaudeDo.Data/CLAUDE.md
git commit -m "docs: update CLAUDE.md files for EF Core migration"