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 andOnModelCreatingsrc/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs— Fluent API for TaskEntitysrc/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs— Fluent API for ListEntitysrc/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs— Fluent API for TagEntitysrc/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs— Fluent API for ListConfigEntitysrc/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs— Fluent API for WorktreeEntitysrc/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs— Fluent API for TaskRunEntitysrc/ClaudeDo.Data/Configuration/SubtaskEntityConfiguration.cs— Fluent API for SubtaskEntitysrc/ClaudeDo.Data/Migrations/— Generated bydotnet ef migrations add
Modified files
src/ClaudeDo.Data/ClaudeDo.Data.csproj— Swap packagessrc/ClaudeDo.Data/Models/TaskEntity.cs— Add navigation propertiessrc/ClaudeDo.Data/Models/ListEntity.cs— Add navigation propertiessrc/ClaudeDo.Data/Models/TagEntity.cs— Add navigation propertiessrc/ClaudeDo.Data/Models/TaskRunEntity.cs— Add navigation propertysrc/ClaudeDo.Data/Models/SubtaskEntity.cs— Add navigation propertysrc/ClaudeDo.Data/Models/WorktreeEntity.cs— Add navigation propertysrc/ClaudeDo.Data/Models/ListConfigEntity.cs— Add navigation propertysrc/ClaudeDo.Data/Repositories/TagRepository.cs— Rewrite to EF Coresrc/ClaudeDo.Data/Repositories/SubtaskRepository.cs— Rewrite to EF Coresrc/ClaudeDo.Data/Repositories/WorktreeRepository.cs— Rewrite to EF Coresrc/ClaudeDo.Data/Repositories/ListRepository.cs— Rewrite to EF Coresrc/ClaudeDo.Data/Repositories/TaskRunRepository.cs— Rewrite to EF Coresrc/ClaudeDo.Data/Repositories/TaskRepository.cs— Rewrite to EF Coresrc/ClaudeDo.App/Program.cs— EF Core DI registrationsrc/ClaudeDo.Worker/Program.cs— EF Core DI registrationsrc/ClaudeDo.Worker/Services/QueueService.cs— Adapt to scoped repos via IDbContextFactorysrc/ClaudeDo.Worker/Services/StaleTaskRecovery.cs— Adapt to scoped repos via IDbContextFactorysrc/ClaudeDo.Worker/Runner/TaskRunner.cs— Adapt to scoped repos via IDbContextFactorysrc/ClaudeDo.Worker/Runner/WorktreeManager.cs— Adapt to scoped repos via IDbContextFactorysrc/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs— Adapt to IDbContextFactorysrc/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs— Adapt to IDbContextFactorysrc/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs— Adapt to IDbContextFactorysrc/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs— Adapt to IDbContextFactorytests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj— Swap packagestests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs— EF Core fixture
Deleted files
src/ClaudeDo.Data/SqliteConnectionFactory.cssrc/ClaudeDo.Data/SchemaInitializer.csschema/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:
- Replace repository fields with a single
IDbContextFactory<ClaudeDoDbContext> _dbFactoryfield. - Update constructor to take
IDbContextFactory<ClaudeDoDbContext>. - 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
IDbContextFactorypattern for singleton consumers -
Step 2: Update ClaudeDo.Data/CLAUDE.md
Rewrite the Infrastructure and Repositories sections:
-
Infrastructure:
ClaudeDoDbContextreplacesSqliteConnectionFactory+SchemaInitializer -
Repositories: "All repositories use EF Core LINQ. Exception:
TaskRepository.GetNextQueuedAgentTaskAsyncusesFromSqlRawfor atomic queue claim." -
Remove Conventions about
ToDb()/FromDb()andDBNull.Value -
Add Conventions about
IEntityTypeConfiguration<T>inConfiguration/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"