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

1723 lines
58 KiB
Markdown

# EF Core Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the raw ADO.NET data layer with Entity Framework Core and LINQ queries.
**Architecture:** Single `ClaudeDoDbContext` in `ClaudeDo.Data` with Fluent API configuration. All 6 repositories rewritten to LINQ. EF Core migrations replace `schema.sql` and `SchemaInitializer`. Atomic queue claim kept as `FromSqlRaw`.
**Tech Stack:** .NET 8.0, Microsoft.EntityFrameworkCore.Sqlite 8.x, EF Core Migrations
**Spec:** `docs/superpowers/specs/2026-04-16-efcore-migration-design.md`
---
## File Structure
### New files
- `src/ClaudeDo.Data/ClaudeDoDbContext.cs` — DbContext with DbSets and `OnModelCreating`
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — Fluent API for TaskEntity
- `src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs` — Fluent API for ListEntity
- `src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs` — Fluent API for TagEntity
- `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs` — Fluent API for ListConfigEntity
- `src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs` — Fluent API for WorktreeEntity
- `src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs` — Fluent API for TaskRunEntity
- `src/ClaudeDo.Data/Configuration/SubtaskEntityConfiguration.cs` — Fluent API for SubtaskEntity
- `src/ClaudeDo.Data/Migrations/` — Generated by `dotnet ef migrations add`
### Modified files
- `src/ClaudeDo.Data/ClaudeDo.Data.csproj` — Swap packages
- `src/ClaudeDo.Data/Models/TaskEntity.cs` — Add navigation properties
- `src/ClaudeDo.Data/Models/ListEntity.cs` — Add navigation properties
- `src/ClaudeDo.Data/Models/TagEntity.cs` — Add navigation properties
- `src/ClaudeDo.Data/Models/TaskRunEntity.cs` — Add navigation property
- `src/ClaudeDo.Data/Models/SubtaskEntity.cs` — Add navigation property
- `src/ClaudeDo.Data/Models/WorktreeEntity.cs` — Add navigation property
- `src/ClaudeDo.Data/Models/ListConfigEntity.cs` — Add navigation property
- `src/ClaudeDo.Data/Repositories/TagRepository.cs` — Rewrite to EF Core
- `src/ClaudeDo.Data/Repositories/SubtaskRepository.cs` — Rewrite to EF Core
- `src/ClaudeDo.Data/Repositories/WorktreeRepository.cs` — Rewrite to EF Core
- `src/ClaudeDo.Data/Repositories/ListRepository.cs` — Rewrite to EF Core
- `src/ClaudeDo.Data/Repositories/TaskRunRepository.cs` — Rewrite to EF Core
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — Rewrite to EF Core
- `src/ClaudeDo.App/Program.cs` — EF Core DI registration
- `src/ClaudeDo.Worker/Program.cs` — EF Core DI registration
- `src/ClaudeDo.Worker/Services/QueueService.cs` — Adapt to scoped repos via IDbContextFactory
- `src/ClaudeDo.Worker/Services/StaleTaskRecovery.cs` — Adapt to scoped repos via IDbContextFactory
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — Adapt to scoped repos via IDbContextFactory
- `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` — Adapt to scoped repos via IDbContextFactory
- `src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs` — Adapt to IDbContextFactory
- `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs` — Adapt to IDbContextFactory
- `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — Adapt to IDbContextFactory
- `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs` — Adapt to IDbContextFactory
- `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — Swap packages
- `tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs` — EF Core fixture
### Deleted files
- `src/ClaudeDo.Data/SqliteConnectionFactory.cs`
- `src/ClaudeDo.Data/SchemaInitializer.cs`
- `schema/schema.sql`
---
### Task 1: Update Package References
**Files:**
- Modify: `src/ClaudeDo.Data/ClaudeDo.Data.csproj`
- Modify: `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
- [ ] **Step 1: Update ClaudeDo.Data.csproj**
Replace package references and remove the embedded resource:
```xml
<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:
```xml
<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**
```bash
git add src/ClaudeDo.Data/ClaudeDo.Data.csproj tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
git commit -m "chore(data): swap Microsoft.Data.Sqlite for EF Core packages"
```
---
### Task 2: Add Navigation Properties to Models
**Files:**
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs`
- Modify: `src/ClaudeDo.Data/Models/ListEntity.cs`
- Modify: `src/ClaudeDo.Data/Models/TagEntity.cs`
- Modify: `src/ClaudeDo.Data/Models/WorktreeEntity.cs`
- Modify: `src/ClaudeDo.Data/Models/ListConfigEntity.cs`
- Modify: `src/ClaudeDo.Data/Models/TaskRunEntity.cs`
- Modify: `src/ClaudeDo.Data/Models/SubtaskEntity.cs`
- [ ] **Step 1: Update TaskEntity.cs**
Add navigation properties at the end of the class, before the closing brace:
```csharp
namespace ClaudeDo.Data.Models;
public enum TaskStatus
{
Manual,
Queued,
Running,
Done,
Failed,
}
public sealed class TaskEntity
{
public required string Id { get; init; }
public required string ListId { get; init; }
public required string Title { get; set; }
public string? Description { get; set; }
public TaskStatus Status { get; set; } = TaskStatus.Manual;
public DateTime? ScheduledFor { get; set; }
public string? Result { get; set; }
public string? LogPath { get; set; }
public required DateTime CreatedAt { get; init; }
public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; }
public string CommitType { get; set; } = "chore";
public string? Model { get; set; }
public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; }
// Navigation properties
public ListEntity List { get; set; } = null!;
public WorktreeEntity? Worktree { get; set; }
public ICollection<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**
```csharp
namespace ClaudeDo.Data.Models;
public sealed class ListEntity
{
public required string Id { get; init; }
public required string Name { get; set; }
public required DateTime CreatedAt { get; init; }
public string? WorkingDir { get; set; }
public string DefaultCommitType { get; set; } = "chore";
// Navigation properties
public ListConfigEntity? Config { get; set; }
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
}
```
- [ ] **Step 3: Update TagEntity.cs**
```csharp
namespace ClaudeDo.Data.Models;
public sealed class TagEntity
{
public long Id { get; init; }
public required string Name { get; set; }
// Navigation properties
public ICollection<ListEntity> Lists { get; set; } = new List<ListEntity>();
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
}
```
- [ ] **Step 4: Update WorktreeEntity.cs**
```csharp
namespace ClaudeDo.Data.Models;
public enum WorktreeState
{
Active,
Merged,
Discarded,
Kept,
}
public sealed class WorktreeEntity
{
public required string TaskId { get; init; }
public required string Path { get; set; }
public required string BranchName { get; set; }
public required string BaseCommit { get; set; }
public string? HeadCommit { get; set; }
public string? DiffStat { get; set; }
public WorktreeState State { get; set; } = WorktreeState.Active;
public required DateTime CreatedAt { get; init; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
}
```
- [ ] **Step 5: Update ListConfigEntity.cs**
```csharp
namespace ClaudeDo.Data.Models;
public sealed class ListConfigEntity
{
public required string ListId { get; init; }
public string? Model { get; set; }
public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; }
// Navigation property
public ListEntity List { get; set; } = null!;
}
```
- [ ] **Step 6: Update TaskRunEntity.cs**
```csharp
namespace ClaudeDo.Data.Models;
public sealed class TaskRunEntity
{
public required string Id { get; init; }
public required string TaskId { get; init; }
public required int RunNumber { get; init; }
public string? SessionId { get; set; }
public required bool IsRetry { get; init; }
public required string Prompt { get; init; }
public string? ResultMarkdown { get; set; }
public string? StructuredOutputJson { get; set; }
public string? ErrorMarkdown { get; set; }
public int? ExitCode { get; set; }
public int? TurnCount { get; set; }
public int? TokensIn { get; set; }
public int? TokensOut { get; set; }
public string? LogPath { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
}
```
- [ ] **Step 7: Update SubtaskEntity.cs**
```csharp
namespace ClaudeDo.Data.Models;
public sealed class SubtaskEntity
{
public required string Id { get; init; }
public required string TaskId { get; init; }
public required string Title { get; set; }
public bool Completed { get; set; }
public int OrderNum { get; set; }
public required DateTime CreatedAt { get; init; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
}
```
- [ ] **Step 8: Commit**
```bash
git add src/ClaudeDo.Data/Models/
git commit -m "feat(data): add navigation properties to all entity models"
```
---
### Task 3: Create ClaudeDoDbContext and Fluent API Configurations
**Files:**
- Create: `src/ClaudeDo.Data/ClaudeDoDbContext.cs`
- Create: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`
- Create: `src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs`
- Create: `src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs`
- Create: `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs`
- Create: `src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs`
- Create: `src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs`
- Create: `src/ClaudeDo.Data/Configuration/SubtaskEntityConfiguration.cs`
- [ ] **Step 1: Create ClaudeDoDbContext.cs**
```csharp
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data;
public class ClaudeDoDbContext : DbContext
{
public ClaudeDoDbContext(DbContextOptions<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**
```csharp
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Configuration;
public class TaskEntityConfiguration : IEntityTypeConfiguration<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**
```csharp
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**
```csharp
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**
```csharp
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**
```csharp
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**
```csharp
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**
```csharp
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**
```bash
git add src/ClaudeDo.Data/ClaudeDoDbContext.cs src/ClaudeDo.Data/Configuration/
git commit -m "feat(data): add ClaudeDoDbContext with Fluent API configurations"
```
---
### Task 4: Rewrite TagRepository
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/TagRepository.cs`
- [ ] **Step 1: Rewrite TagRepository to EF Core**
```csharp
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class TagRepository
{
private readonly ClaudeDoDbContext _context;
public TagRepository(ClaudeDoDbContext context) => _context = context;
public async Task<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**
```bash
git add src/ClaudeDo.Data/Repositories/TagRepository.cs
git commit -m "refactor(data): rewrite TagRepository to EF Core LINQ"
```
---
### Task 5: Rewrite SubtaskRepository
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/SubtaskRepository.cs`
- [ ] **Step 1: Rewrite SubtaskRepository to EF Core**
```csharp
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class SubtaskRepository
{
private readonly ClaudeDoDbContext _context;
public SubtaskRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default)
{
_context.Subtasks.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task<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**
```bash
git add src/ClaudeDo.Data/Repositories/SubtaskRepository.cs
git commit -m "refactor(data): rewrite SubtaskRepository to EF Core LINQ"
```
---
### Task 6: Rewrite WorktreeRepository
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/WorktreeRepository.cs`
- [ ] **Step 1: Rewrite WorktreeRepository to EF Core**
```csharp
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class WorktreeRepository
{
private readonly ClaudeDoDbContext _context;
public WorktreeRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default)
{
_context.Worktrees.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task<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**
```bash
git add src/ClaudeDo.Data/Repositories/WorktreeRepository.cs
git commit -m "refactor(data): rewrite WorktreeRepository to EF Core LINQ"
```
---
### Task 7: Rewrite ListRepository
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/ListRepository.cs`
- [ ] **Step 1: Rewrite ListRepository to EF Core**
```csharp
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class ListRepository
{
private readonly ClaudeDoDbContext _context;
public ListRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
{
_context.Lists.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
{
_context.Lists.Update(entity);
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(string listId, CancellationToken ct = default)
{
await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct);
}
public async Task<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**
```bash
git add src/ClaudeDo.Data/Repositories/ListRepository.cs
git commit -m "refactor(data): rewrite ListRepository to EF Core LINQ"
```
---
### Task 8: Rewrite TaskRunRepository
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/TaskRunRepository.cs`
- [ ] **Step 1: Rewrite TaskRunRepository to EF Core**
```csharp
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class TaskRunRepository
{
private readonly ClaudeDoDbContext _context;
public TaskRunRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default)
{
_context.TaskRuns.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default)
{
_context.TaskRuns.Update(entity);
await _context.SaveChangesAsync(ct);
}
public async Task<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**
```bash
git add src/ClaudeDo.Data/Repositories/TaskRunRepository.cs
git commit -m "refactor(data): rewrite TaskRunRepository to EF Core LINQ"
```
---
### Task 9: Rewrite TaskRepository
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs`
This is the most complex repository. The atomic queue claim stays as raw SQL.
- [ ] **Step 1: Rewrite TaskRepository to EF Core**
```csharp
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Repositories;
public sealed class TaskRepository
{
private readonly ClaudeDoDbContext _context;
public TaskRepository(ClaudeDoDbContext context) => _context = context;
#region CRUD
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
{
_context.Tasks.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
{
_context.Tasks.Update(entity);
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{
await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct);
}
public async Task<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**
```bash
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs
git commit -m "refactor(data): rewrite TaskRepository to EF Core LINQ with raw SQL queue claim"
```
---
### Task 10: Delete Old Infrastructure and Schema
**Files:**
- Delete: `src/ClaudeDo.Data/SqliteConnectionFactory.cs`
- Delete: `src/ClaudeDo.Data/SchemaInitializer.cs`
- Delete: `schema/schema.sql`
- [ ] **Step 1: Delete SqliteConnectionFactory.cs**
```bash
rm src/ClaudeDo.Data/SqliteConnectionFactory.cs
```
- [ ] **Step 2: Delete SchemaInitializer.cs**
```bash
rm src/ClaudeDo.Data/SchemaInitializer.cs
```
- [ ] **Step 3: Delete schema.sql**
```bash
rm schema/schema.sql
```
- [ ] **Step 4: Commit**
```bash
git add -u src/ClaudeDo.Data/SqliteConnectionFactory.cs src/ClaudeDo.Data/SchemaInitializer.cs schema/schema.sql
git commit -m "chore(data): remove raw ADO.NET infrastructure and schema.sql"
```
---
### Task 11: Update DI Registration — Worker
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs`
- [ ] **Step 1: Rewrite Worker Program.cs DI section**
Replace lines 1-28 with EF Core registration. The `SqliteConnectionFactory` and `SchemaInitializer` calls are replaced by `AddDbContext` and `Database.Migrate()`.
The full updated `Program.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Services;
using Microsoft.EntityFrameworkCore;
var cfg = WorkerConfig.Load();
var builder = WebApplication.CreateBuilder(args);
// When launched by the Windows SCM, speak the Service Control Protocol so SCM
// doesn't think we crashed (~30s timeout). No-op when running interactively.
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
builder.Services.AddSingleton(cfg);
// EF Core DbContext — scoped lifetime (one context per scope/request).
builder.Services.AddDbContext<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**
```bash
git add src/ClaudeDo.Worker/Program.cs
git commit -m "refactor(worker): switch DI from raw ADO.NET to EF Core"
```
---
### Task 12: Update DI Registration — App
**Files:**
- Modify: `src/ClaudeDo.App/Program.cs`
- [ ] **Step 1: Rewrite App Program.cs DI section**
The App uses `ServiceCollection` directly (no WebApplication builder). ViewModels are singletons that need database access — they take `IDbContextFactory<ClaudeDoDbContext>` instead of repositories.
```csharp
using Avalonia;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
namespace ClaudeDo.App;
sealed class Program
{
[STAThread]
public static void Main(string[] args)
{
var services = BuildServices();
App.Services = services;
// Apply EF Core migrations at startup.
using (var scope = services.CreateScope())
{
scope.ServiceProvider.GetRequiredService<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**
```bash
git add src/ClaudeDo.App/Program.cs
git commit -m "refactor(app): switch DI from raw ADO.NET to EF Core"
```
---
### Task 13: Update ViewModel and Worker Service Consumers
All singleton ViewModels and Worker services currently take repository instances directly. Since repositories are now scoped, these consumers must take `IDbContextFactory<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):**
```csharp
private readonly TaskRepository _taskRepo;
public QueueService(TaskRepository taskRepo, ...) { _taskRepo = taskRepo; ... }
// In method:
var task = await _taskRepo.GetNextQueuedAgentTaskAsync(now, ct);
```
**After:**
```csharp
private readonly IDbContextFactory<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:
```csharp
// 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`:
```csharp
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**
```bash
git add src/ClaudeDo.Ui/ViewModels/ src/ClaudeDo.Worker/Services/ src/ClaudeDo.Worker/Runner/ src/ClaudeDo.App/Program.cs src/ClaudeDo.Worker/Program.cs
git commit -m "refactor(ui,worker): switch consumers from direct repos to IDbContextFactory"
```
---
### Task 14: Generate Initial EF Core Migration
**Files:**
- Create: `src/ClaudeDo.Data/Migrations/` (generated)
- [ ] **Step 1: Install EF Core tools if needed**
Run: `dotnet tool list -g | grep dotnet-ef || dotnet tool install --global dotnet-ef --version 8.0.11`
- [ ] **Step 2: Generate the initial migration**
Run from the repo root:
```bash
dotnet ef migrations add InitialCreate --project src/ClaudeDo.Data --startup-project src/ClaudeDo.Worker
```
Expected: Creates `src/ClaudeDo.Data/Migrations/` with `*_InitialCreate.cs` and `ClaudeDoDbContextModelSnapshot.cs`.
- [ ] **Step 3: Add existing-database compatibility shim**
Edit the generated `*_InitialCreate.cs` `Up` method. Wrap all `CreateTable` calls in a check for existing tables. Add this at the very start of `Up`:
```csharp
protected override void Up(MigrationBuilder migrationBuilder)
{
// If upgrading from the old schema.sql-based setup, tables already exist.
// Check for the 'lists' table as a sentinel — if it exists, skip DDL and
// just ensure the migration is recorded.
migrationBuilder.Sql("""
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
""");
// The rest of the migration uses IF NOT EXISTS for safety.
// ... (keep the generated code but add IF NOT EXISTS to each CreateTable)
}
```
Alternatively, since SQLite `CREATE TABLE IF NOT EXISTS` is supported, prefix each generated `CREATE TABLE` statement. The simplest approach: in `Up`, replace each `migrationBuilder.CreateTable(...)` with equivalent `migrationBuilder.Sql("CREATE TABLE IF NOT EXISTS ...")` calls. This is a one-time adaptation.
- [ ] **Step 4: Verify migration applies to a fresh database**
```bash
rm -f /tmp/test_migration.db
dotnet ef database update --project src/ClaudeDo.Data --startup-project src/ClaudeDo.Worker -- --DbPath /tmp/test_migration.db
```
Or simply run the Worker pointed at a new DB path and check it starts.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Migrations/
git commit -m "feat(data): add InitialCreate EF Core migration with existing-DB compat shim"
```
---
### Task 15: Update Test Infrastructure
**Files:**
- Modify: `tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs` (and any other test files that use DbFixture)
- [ ] **Step 1: Rewrite DbFixture to use EF Core**
```csharp
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Tests.Infrastructure;
public sealed class DbFixture : IDisposable
{
public string DbPath { get; }
public DbContextOptions<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`:
```csharp
private async Task<(WorktreeManager mgr, WorktreeRepository wtRepo)> CreateManagerAsync(
TaskEntity task, ListEntity list, string strategy = "sibling", string? centralRoot = null)
{
var db = new DbFixture();
_dbFixtures.Add(db);
var context = db.CreateContext();
var listRepo = new ListRepository(context);
var taskRepo = new TaskRepository(context);
var wtRepo = new WorktreeRepository(context);
// ... seed and create manager as before, but using context-based repos
```
Apply the same pattern to all test files that use `DbFixture.Factory` to construct repositories.
- [ ] **Step 3: Run tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
Expected: All existing tests pass.
- [ ] **Step 4: Commit**
```bash
git add tests/ClaudeDo.Worker.Tests/
git commit -m "refactor(tests): switch test infrastructure to EF Core DbContext"
```
---
### Task 16: Full Build and Test Verification
- [ ] **Step 1: Clean build**
Run: `dotnet build ClaudeDo.slnx --no-incremental`
Expected: Build succeeds with zero errors.
- [ ] **Step 2: Run all tests**
Run: `dotnet test ClaudeDo.slnx`
Expected: All tests pass.
- [ ] **Step 3: Smoke test — start Worker**
Run the Worker and verify it starts, creates/migrates the DB, and logs the listening message.
- [ ] **Step 4: Smoke test — start App**
Run the App and verify it starts, connects to Worker (if running), and the UI loads.
- [ ] **Step 5: Verify no references to old types remain**
```bash
grep -rn "SqliteConnectionFactory\|SchemaInitializer\|Microsoft\.Data\.Sqlite\|using Microsoft\.Data\.Sqlite" src/ tests/ --include="*.cs" --include="*.csproj"
```
Expected: Zero matches.
- [ ] **Step 6: Final commit if any cleanup was needed**
```bash
git add -A
git commit -m "chore(data): final cleanup after EF Core migration"
```
---
### Task 17: Update Documentation
**Files:**
- Modify: `CLAUDE.md`
- Modify: `src/ClaudeDo.Data/CLAUDE.md`
- [ ] **Step 1: Update project CLAUDE.md**
Update the Tech Stack, Conventions, and Key Paths sections to reflect EF Core:
- Replace "SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM" with "SQLite (WAL mode) via Entity Framework Core + Microsoft.EntityFrameworkCore.Sqlite"
- Remove references to `schema/schema.sql`, `SqliteConnectionFactory`, `SchemaInitializer`
- Update repository description to mention LINQ instead of raw SQL
- Add note about `IDbContextFactory` pattern for singleton consumers
- [ ] **Step 2: Update ClaudeDo.Data/CLAUDE.md**
Rewrite the Infrastructure and Repositories sections:
- Infrastructure: `ClaudeDoDbContext` replaces `SqliteConnectionFactory` + `SchemaInitializer`
- Repositories: "All repositories use EF Core LINQ. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim."
- Remove Conventions about `ToDb()`/`FromDb()` and `DBNull.Value`
- Add Conventions about `IEntityTypeConfiguration<T>` in `Configuration/` folder
- [ ] **Step 3: Commit**
```bash
git add CLAUDE.md src/ClaudeDo.Data/CLAUDE.md
git commit -m "docs: update CLAUDE.md files for EF Core migration"
```