diff --git a/.gitignore b/.gitignore index a4a416c..13cbffe 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ Desktop.ini *.log *.tmp *.bak +design-time.db diff --git a/CLAUDE.md b/CLAUDE.md index 1e8a0c4..da3a252 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`): ## Tech Stack - .NET 8.0, Avalonia 12.0.0 (Fluent theme) -- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM +- SQLite (WAL mode) via Entity Framework Core (Microsoft.EntityFrameworkCore.Sqlite) - SignalR for real-time IPC - CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`) - Git worktrees for task isolation @@ -27,12 +27,14 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`): - Worker config: `~/.todo-app/worker.config.json` - Logs: `~/.todo-app/logs/` - Worktrees: configured per worker (sibling or central strategy) -- Schema: `schema/schema.sql` (embedded resource in ClaudeDo.Data) ## Conventions - Repository pattern — each entity has its own async repository - All data operations are async with CancellationToken support +- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data) +- `IDbContextFactory` used by singleton consumers (e.g. Worker) +- Entity configuration via `IEntityTypeConfiguration` in Configuration/ folder - Task status flow: Manual | Queued -> Running -> Done | Failed - Worktree state flow: Active -> Merged | Discarded | Kept - Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup diff --git a/docs/superpowers/plans/2026-04-16-efcore-migration.md b/docs/superpowers/plans/2026-04-16-efcore-migration.md new file mode 100644 index 0000000..b5fee35 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-efcore-migration.md @@ -0,0 +1,1722 @@ +# EF Core Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the raw ADO.NET data layer with Entity Framework Core and LINQ queries. + +**Architecture:** Single `ClaudeDoDbContext` in `ClaudeDo.Data` with Fluent API configuration. All 6 repositories rewritten to LINQ. EF Core migrations replace `schema.sql` and `SchemaInitializer`. Atomic queue claim kept as `FromSqlRaw`. + +**Tech Stack:** .NET 8.0, Microsoft.EntityFrameworkCore.Sqlite 8.x, EF Core Migrations + +**Spec:** `docs/superpowers/specs/2026-04-16-efcore-migration-design.md` + +--- + +## File Structure + +### New files +- `src/ClaudeDo.Data/ClaudeDoDbContext.cs` — DbContext with DbSets and `OnModelCreating` +- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — Fluent API for TaskEntity +- `src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs` — Fluent API for ListEntity +- `src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs` — Fluent API for TagEntity +- `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs` — Fluent API for ListConfigEntity +- `src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs` — Fluent API for WorktreeEntity +- `src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs` — Fluent API for TaskRunEntity +- `src/ClaudeDo.Data/Configuration/SubtaskEntityConfiguration.cs` — Fluent API for SubtaskEntity +- `src/ClaudeDo.Data/Migrations/` — Generated by `dotnet ef migrations add` + +### Modified files +- `src/ClaudeDo.Data/ClaudeDo.Data.csproj` — Swap packages +- `src/ClaudeDo.Data/Models/TaskEntity.cs` — Add navigation properties +- `src/ClaudeDo.Data/Models/ListEntity.cs` — Add navigation properties +- `src/ClaudeDo.Data/Models/TagEntity.cs` — Add navigation properties +- `src/ClaudeDo.Data/Models/TaskRunEntity.cs` — Add navigation property +- `src/ClaudeDo.Data/Models/SubtaskEntity.cs` — Add navigation property +- `src/ClaudeDo.Data/Models/WorktreeEntity.cs` — Add navigation property +- `src/ClaudeDo.Data/Models/ListConfigEntity.cs` — Add navigation property +- `src/ClaudeDo.Data/Repositories/TagRepository.cs` — Rewrite to EF Core +- `src/ClaudeDo.Data/Repositories/SubtaskRepository.cs` — Rewrite to EF Core +- `src/ClaudeDo.Data/Repositories/WorktreeRepository.cs` — Rewrite to EF Core +- `src/ClaudeDo.Data/Repositories/ListRepository.cs` — Rewrite to EF Core +- `src/ClaudeDo.Data/Repositories/TaskRunRepository.cs` — Rewrite to EF Core +- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — Rewrite to EF Core +- `src/ClaudeDo.App/Program.cs` — EF Core DI registration +- `src/ClaudeDo.Worker/Program.cs` — EF Core DI registration +- `src/ClaudeDo.Worker/Services/QueueService.cs` — Adapt to scoped repos via IDbContextFactory +- `src/ClaudeDo.Worker/Services/StaleTaskRecovery.cs` — Adapt to scoped repos via IDbContextFactory +- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — Adapt to scoped repos via IDbContextFactory +- `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` — Adapt to scoped repos via IDbContextFactory +- `src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs` — Adapt to IDbContextFactory +- `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs` — Adapt to IDbContextFactory +- `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — Adapt to IDbContextFactory +- `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs` — Adapt to IDbContextFactory +- `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — Swap packages +- `tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs` — EF Core fixture + +### Deleted files +- `src/ClaudeDo.Data/SqliteConnectionFactory.cs` +- `src/ClaudeDo.Data/SchemaInitializer.cs` +- `schema/schema.sql` + +--- + +### Task 1: Update Package References + +**Files:** +- Modify: `src/ClaudeDo.Data/ClaudeDo.Data.csproj` +- Modify: `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` + +- [ ] **Step 1: Update ClaudeDo.Data.csproj** + +Replace package references and remove the embedded resource: + +```xml + + + + net8.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + +``` + +- [ ] **Step 2: Update Worker.Tests.csproj** + +Replace `Microsoft.Data.Sqlite` with EF Core: + +```xml + + + + + + + +``` + +- [ ] **Step 3: Verify packages restore** + +Run: `dotnet restore ClaudeDo.slnx` +Expected: Restore succeeds (build will fail — repositories still reference old types). + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Data/ClaudeDo.Data.csproj tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj +git commit -m "chore(data): swap Microsoft.Data.Sqlite for EF Core packages" +``` + +--- + +### Task 2: Add Navigation Properties to Models + +**Files:** +- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs` +- Modify: `src/ClaudeDo.Data/Models/ListEntity.cs` +- Modify: `src/ClaudeDo.Data/Models/TagEntity.cs` +- Modify: `src/ClaudeDo.Data/Models/WorktreeEntity.cs` +- Modify: `src/ClaudeDo.Data/Models/ListConfigEntity.cs` +- Modify: `src/ClaudeDo.Data/Models/TaskRunEntity.cs` +- Modify: `src/ClaudeDo.Data/Models/SubtaskEntity.cs` + +- [ ] **Step 1: Update TaskEntity.cs** + +Add navigation properties at the end of the class, before the closing brace: + +```csharp +namespace ClaudeDo.Data.Models; + +public enum TaskStatus +{ + Manual, + Queued, + Running, + Done, + Failed, +} + +public sealed class TaskEntity +{ + public required string Id { get; init; } + public required string ListId { get; init; } + public required string Title { get; set; } + public string? Description { get; set; } + public TaskStatus Status { get; set; } = TaskStatus.Manual; + public DateTime? ScheduledFor { get; set; } + public string? Result { get; set; } + public string? LogPath { get; set; } + public required DateTime CreatedAt { get; init; } + public DateTime? StartedAt { get; set; } + public DateTime? FinishedAt { get; set; } + public string CommitType { get; set; } = "chore"; + public string? Model { get; set; } + public string? SystemPrompt { get; set; } + public string? AgentPath { get; set; } + + // Navigation properties + public ListEntity List { get; set; } = null!; + public WorktreeEntity? Worktree { get; set; } + public ICollection Tags { get; set; } = new List(); + public ICollection Runs { get; set; } = new List(); + public ICollection Subtasks { get; set; } = new List(); +} +``` + +- [ ] **Step 2: Update ListEntity.cs** + +```csharp +namespace ClaudeDo.Data.Models; + +public sealed class ListEntity +{ + public required string Id { get; init; } + public required string Name { get; set; } + public required DateTime CreatedAt { get; init; } + public string? WorkingDir { get; set; } + public string DefaultCommitType { get; set; } = "chore"; + + // Navigation properties + public ListConfigEntity? Config { get; set; } + public ICollection Tasks { get; set; } = new List(); + public ICollection Tags { get; set; } = new List(); +} +``` + +- [ ] **Step 3: Update TagEntity.cs** + +```csharp +namespace ClaudeDo.Data.Models; + +public sealed class TagEntity +{ + public long Id { get; init; } + public required string Name { get; set; } + + // Navigation properties + public ICollection Lists { get; set; } = new List(); + public ICollection Tasks { get; set; } = new List(); +} +``` + +- [ ] **Step 4: Update WorktreeEntity.cs** + +```csharp +namespace ClaudeDo.Data.Models; + +public enum WorktreeState +{ + Active, + Merged, + Discarded, + Kept, +} + +public sealed class WorktreeEntity +{ + public required string TaskId { get; init; } + public required string Path { get; set; } + public required string BranchName { get; set; } + public required string BaseCommit { get; set; } + public string? HeadCommit { get; set; } + public string? DiffStat { get; set; } + public WorktreeState State { get; set; } = WorktreeState.Active; + public required DateTime CreatedAt { get; init; } + + // Navigation property + public TaskEntity Task { get; set; } = null!; +} +``` + +- [ ] **Step 5: Update ListConfigEntity.cs** + +```csharp +namespace ClaudeDo.Data.Models; + +public sealed class ListConfigEntity +{ + public required string ListId { get; init; } + public string? Model { get; set; } + public string? SystemPrompt { get; set; } + public string? AgentPath { get; set; } + + // Navigation property + public ListEntity List { get; set; } = null!; +} +``` + +- [ ] **Step 6: Update TaskRunEntity.cs** + +```csharp +namespace ClaudeDo.Data.Models; + +public sealed class TaskRunEntity +{ + public required string Id { get; init; } + public required string TaskId { get; init; } + public required int RunNumber { get; init; } + public string? SessionId { get; set; } + public required bool IsRetry { get; init; } + public required string Prompt { get; init; } + public string? ResultMarkdown { get; set; } + public string? StructuredOutputJson { get; set; } + public string? ErrorMarkdown { get; set; } + public int? ExitCode { get; set; } + public int? TurnCount { get; set; } + public int? TokensIn { get; set; } + public int? TokensOut { get; set; } + public string? LogPath { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? FinishedAt { get; set; } + + // Navigation property + public TaskEntity Task { get; set; } = null!; +} +``` + +- [ ] **Step 7: Update SubtaskEntity.cs** + +```csharp +namespace ClaudeDo.Data.Models; + +public sealed class SubtaskEntity +{ + public required string Id { get; init; } + public required string TaskId { get; init; } + public required string Title { get; set; } + public bool Completed { get; set; } + public int OrderNum { get; set; } + public required DateTime CreatedAt { get; init; } + + // Navigation property + public TaskEntity Task { get; set; } = null!; +} +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/ClaudeDo.Data/Models/ +git commit -m "feat(data): add navigation properties to all entity models" +``` + +--- + +### Task 3: Create ClaudeDoDbContext and Fluent API Configurations + +**Files:** +- Create: `src/ClaudeDo.Data/ClaudeDoDbContext.cs` +- Create: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` +- Create: `src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs` +- Create: `src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs` +- Create: `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs` +- Create: `src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs` +- Create: `src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs` +- Create: `src/ClaudeDo.Data/Configuration/SubtaskEntityConfiguration.cs` + +- [ ] **Step 1: Create ClaudeDoDbContext.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data; + +public class ClaudeDoDbContext : DbContext +{ + public ClaudeDoDbContext(DbContextOptions options) : base(options) { } + + public DbSet Tasks => Set(); + public DbSet Lists => Set(); + public DbSet Tags => Set(); + public DbSet ListConfigs => Set(); + public DbSet Worktrees => Set(); + public DbSet TaskRuns => Set(); + public DbSet Subtasks => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly); + } +} +``` + +- [ ] **Step 2: Create TaskEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Configuration; + +public class TaskEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tasks"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).HasColumnName("id"); + builder.Property(t => t.ListId).HasColumnName("list_id").IsRequired(); + builder.Property(t => t.Title).HasColumnName("title").IsRequired(); + builder.Property(t => t.Description).HasColumnName("description"); + builder.Property(t => t.Status).HasColumnName("status").IsRequired() + .HasConversion( + v => v switch + { + TaskStatus.Manual => "manual", + TaskStatus.Queued => "queued", + TaskStatus.Running => "running", + TaskStatus.Done => "done", + TaskStatus.Failed => "failed", + _ => throw new ArgumentOutOfRangeException() + }, + v => v switch + { + "manual" => TaskStatus.Manual, + "queued" => TaskStatus.Queued, + "running" => TaskStatus.Running, + "done" => TaskStatus.Done, + "failed" => TaskStatus.Failed, + _ => throw new ArgumentOutOfRangeException() + }); + builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for"); + builder.Property(t => t.Result).HasColumnName("result"); + builder.Property(t => t.LogPath).HasColumnName("log_path"); + builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired(); + builder.Property(t => t.StartedAt).HasColumnName("started_at"); + builder.Property(t => t.FinishedAt).HasColumnName("finished_at"); + builder.Property(t => t.CommitType).HasColumnName("commit_type").IsRequired().HasDefaultValue("chore"); + builder.Property(t => t.Model).HasColumnName("model"); + builder.Property(t => t.SystemPrompt).HasColumnName("system_prompt"); + builder.Property(t => t.AgentPath).HasColumnName("agent_path"); + + builder.HasOne(t => t.List) + .WithMany(l => l.Tasks) + .HasForeignKey(t => t.ListId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(t => t.Worktree) + .WithOne(w => w.Task) + .HasForeignKey(w => w.TaskId); + + builder.HasMany(t => t.Tags) + .WithMany(tag => tag.Tasks) + .UsingEntity("task_tags", + l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade), + r => r.HasOne(typeof(TaskEntity)).WithMany().HasForeignKey("task_id").OnDelete(DeleteBehavior.Cascade), + j => + { + j.HasKey("task_id", "tag_id"); + j.ToTable("task_tags"); + }); + + builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id"); + builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status"); + } +} +``` + +- [ ] **Step 3: Create ListEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class ListEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("lists"); + + builder.HasKey(l => l.Id); + builder.Property(l => l.Id).HasColumnName("id"); + builder.Property(l => l.Name).HasColumnName("name").IsRequired(); + builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired(); + builder.Property(l => l.WorkingDir).HasColumnName("working_dir"); + builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore"); + + builder.HasOne(l => l.Config) + .WithOne(c => c.List) + .HasForeignKey(c => c.ListId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(l => l.Tags) + .WithMany(tag => tag.Lists) + .UsingEntity("list_tags", + l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade), + r => r.HasOne(typeof(ListEntity)).WithMany().HasForeignKey("list_id").OnDelete(DeleteBehavior.Cascade), + j => + { + j.HasKey("list_id", "tag_id"); + j.ToTable("list_tags"); + }); + } +} +``` + +- [ ] **Step 4: Create TagEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class TagEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tags"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).HasColumnName("id").ValueGeneratedOnAdd(); + builder.Property(t => t.Name).HasColumnName("name").IsRequired(); + builder.HasIndex(t => t.Name).IsUnique(); + + builder.HasData( + new TagEntity { Id = 1, Name = "agent" }, + new TagEntity { Id = 2, Name = "manual" }); + } +} +``` + +- [ ] **Step 5: Create ListConfigEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class ListConfigEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("list_config"); + + builder.HasKey(c => c.ListId); + builder.Property(c => c.ListId).HasColumnName("list_id"); + builder.Property(c => c.Model).HasColumnName("model"); + builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt"); + builder.Property(c => c.AgentPath).HasColumnName("agent_path"); + } +} +``` + +- [ ] **Step 6: Create WorktreeEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class WorktreeEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("worktrees"); + + builder.HasKey(w => w.TaskId); + builder.Property(w => w.TaskId).HasColumnName("task_id"); + builder.Property(w => w.Path).HasColumnName("path").IsRequired(); + builder.Property(w => w.BranchName).HasColumnName("branch_name").IsRequired(); + builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired(); + builder.Property(w => w.HeadCommit).HasColumnName("head_commit"); + builder.Property(w => w.DiffStat).HasColumnName("diff_stat"); + builder.Property(w => w.State).HasColumnName("state").IsRequired() + .HasDefaultValue(WorktreeState.Active) + .HasConversion( + v => v switch + { + WorktreeState.Active => "active", + WorktreeState.Merged => "merged", + WorktreeState.Discarded => "discarded", + WorktreeState.Kept => "kept", + _ => throw new ArgumentOutOfRangeException() + }, + v => v switch + { + "active" => WorktreeState.Active, + "merged" => WorktreeState.Merged, + "discarded" => WorktreeState.Discarded, + "kept" => WorktreeState.Kept, + _ => throw new ArgumentOutOfRangeException() + }); + builder.Property(w => w.CreatedAt).HasColumnName("created_at").IsRequired(); + } +} +``` + +- [ ] **Step 7: Create TaskRunEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class TaskRunEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("task_runs"); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).HasColumnName("id"); + builder.Property(r => r.TaskId).HasColumnName("task_id").IsRequired(); + builder.Property(r => r.RunNumber).HasColumnName("run_number").IsRequired(); + builder.Property(r => r.SessionId).HasColumnName("session_id"); + builder.Property(r => r.IsRetry).HasColumnName("is_retry").IsRequired().HasDefaultValue(false); + builder.Property(r => r.Prompt).HasColumnName("prompt").IsRequired(); + builder.Property(r => r.ResultMarkdown).HasColumnName("result_markdown"); + builder.Property(r => r.StructuredOutputJson).HasColumnName("structured_output"); + builder.Property(r => r.ErrorMarkdown).HasColumnName("error_markdown"); + builder.Property(r => r.ExitCode).HasColumnName("exit_code"); + builder.Property(r => r.TurnCount).HasColumnName("turn_count"); + builder.Property(r => r.TokensIn).HasColumnName("tokens_in"); + builder.Property(r => r.TokensOut).HasColumnName("tokens_out"); + builder.Property(r => r.LogPath).HasColumnName("log_path"); + builder.Property(r => r.StartedAt).HasColumnName("started_at"); + builder.Property(r => r.FinishedAt).HasColumnName("finished_at"); + + builder.HasOne(r => r.Task) + .WithMany(t => t.Runs) + .HasForeignKey(r => r.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(r => r.TaskId).HasDatabaseName("idx_task_runs_task_id"); + } +} +``` + +- [ ] **Step 8: Create SubtaskEntityConfiguration.cs** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class SubtaskEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("subtasks"); + + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).HasColumnName("id"); + builder.Property(s => s.TaskId).HasColumnName("task_id").IsRequired(); + builder.Property(s => s.Title).HasColumnName("title").IsRequired(); + builder.Property(s => s.Completed).HasColumnName("completed").IsRequired().HasDefaultValue(false); + builder.Property(s => s.OrderNum).HasColumnName("order_num").IsRequired(); + builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.HasOne(s => s.Task) + .WithMany(t => t.Subtasks) + .HasForeignKey(s => s.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(s => s.TaskId).HasDatabaseName("idx_subtasks_task_id"); + } +} +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/ClaudeDo.Data/ClaudeDoDbContext.cs src/ClaudeDo.Data/Configuration/ +git commit -m "feat(data): add ClaudeDoDbContext with Fluent API configurations" +``` + +--- + +### Task 4: Rewrite TagRepository + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/TagRepository.cs` + +- [ ] **Step 1: Rewrite TagRepository to EF Core** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class TagRepository +{ + private readonly ClaudeDoDbContext _context; + + public TagRepository(ClaudeDoDbContext context) => _context = context; + + public async Task> GetAllAsync(CancellationToken ct = default) + { + return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct); + } + + public async Task GetOrCreateAsync(string name, CancellationToken ct = default) + { + var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct); + if (existing is not null) + return existing.Id; + + var tag = new TagEntity { Name = name }; + _context.Tags.Add(tag); + await _context.SaveChangesAsync(ct); + return tag.Id; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/TagRepository.cs +git commit -m "refactor(data): rewrite TagRepository to EF Core LINQ" +``` + +--- + +### Task 5: Rewrite SubtaskRepository + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/SubtaskRepository.cs` + +- [ ] **Step 1: Rewrite SubtaskRepository to EF Core** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class SubtaskRepository +{ + private readonly ClaudeDoDbContext _context; + + public SubtaskRepository(ClaudeDoDbContext context) => _context = context; + + public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default) + { + _context.Subtasks.Add(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default) + { + return await _context.Subtasks + .Where(s => s.TaskId == taskId) + .OrderBy(s => s.OrderNum) + .ToListAsync(ct); + } + + public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default) + { + _context.Subtasks.Update(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(string subtaskId, CancellationToken ct = default) + { + await _context.Subtasks.Where(s => s.Id == subtaskId).ExecuteDeleteAsync(ct); + } + + public async Task DeleteByTaskIdAsync(string taskId, CancellationToken ct = default) + { + await _context.Subtasks.Where(s => s.TaskId == taskId).ExecuteDeleteAsync(ct); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/SubtaskRepository.cs +git commit -m "refactor(data): rewrite SubtaskRepository to EF Core LINQ" +``` + +--- + +### Task 6: Rewrite WorktreeRepository + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/WorktreeRepository.cs` + +- [ ] **Step 1: Rewrite WorktreeRepository to EF Core** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class WorktreeRepository +{ + private readonly ClaudeDoDbContext _context; + + public WorktreeRepository(ClaudeDoDbContext context) => _context = context; + + public async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default) + { + _context.Worktrees.Add(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task GetByTaskIdAsync(string taskId, CancellationToken ct = default) + { + return await _context.Worktrees.FirstOrDefaultAsync(w => w.TaskId == taskId, ct); + } + + public async Task UpdateHeadAsync(string taskId, string headCommit, string? diffStat, CancellationToken ct = default) + { + await _context.Worktrees + .Where(w => w.TaskId == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(w => w.HeadCommit, headCommit) + .SetProperty(w => w.DiffStat, diffStat), ct); + } + + public async Task SetStateAsync(string taskId, WorktreeState state, CancellationToken ct = default) + { + await _context.Worktrees + .Where(w => w.TaskId == taskId) + .ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), ct); + } + + public async Task DeleteAsync(string taskId, CancellationToken ct = default) + { + await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/WorktreeRepository.cs +git commit -m "refactor(data): rewrite WorktreeRepository to EF Core LINQ" +``` + +--- + +### Task 7: Rewrite ListRepository + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/ListRepository.cs` + +- [ ] **Step 1: Rewrite ListRepository to EF Core** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class ListRepository +{ + private readonly ClaudeDoDbContext _context; + + public ListRepository(ClaudeDoDbContext context) => _context = context; + + public async Task AddAsync(ListEntity entity, CancellationToken ct = default) + { + _context.Lists.Add(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default) + { + _context.Lists.Update(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(string listId, CancellationToken ct = default) + { + await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct); + } + + public async Task GetByIdAsync(string listId, CancellationToken ct = default) + { + return await _context.Lists.FirstOrDefaultAsync(l => l.Id == listId, ct); + } + + public async Task> GetAllAsync(CancellationToken ct = default) + { + return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct); + } + + // Tag management via junction table + + public async Task> GetTagsAsync(string listId, CancellationToken ct = default) + { + return await _context.Lists + .Where(l => l.Id == listId) + .SelectMany(l => l.Tags) + .ToListAsync(ct); + } + + public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default) + { + var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct); + var tag = await _context.Tags.FindAsync([tagId], ct); + if (tag is not null && !list.Tags.Any(t => t.Id == tagId)) + { + list.Tags.Add(tag); + await _context.SaveChangesAsync(ct); + } + } + + public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default) + { + var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct); + var tag = list.Tags.FirstOrDefault(t => t.Id == tagId); + if (tag is not null) + { + list.Tags.Remove(tag); + await _context.SaveChangesAsync(ct); + } + } + + // Config management (1:1 with list) + + public async Task GetConfigAsync(string listId, CancellationToken ct = default) + { + return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct); + } + + public async Task UpsertConfigAsync(ListConfigEntity config, CancellationToken ct = default) + { + var existing = await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == config.ListId, ct); + if (existing is null) + { + _context.ListConfigs.Add(config); + } + else + { + existing.Model = config.Model; + existing.SystemPrompt = config.SystemPrompt; + existing.AgentPath = config.AgentPath; + } + await _context.SaveChangesAsync(ct); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/ListRepository.cs +git commit -m "refactor(data): rewrite ListRepository to EF Core LINQ" +``` + +--- + +### Task 8: Rewrite TaskRunRepository + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/TaskRunRepository.cs` + +- [ ] **Step 1: Rewrite TaskRunRepository to EF Core** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class TaskRunRepository +{ + private readonly ClaudeDoDbContext _context; + + public TaskRunRepository(ClaudeDoDbContext context) => _context = context; + + public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default) + { + _context.TaskRuns.Add(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default) + { + _context.TaskRuns.Update(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task GetByIdAsync(string id, CancellationToken ct = default) + { + return await _context.TaskRuns.FirstOrDefaultAsync(r => r.Id == id, ct); + } + + public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default) + { + return await _context.TaskRuns + .Where(r => r.TaskId == taskId) + .OrderBy(r => r.RunNumber) + .ToListAsync(ct); + } + + public async Task GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default) + { + return await _context.TaskRuns + .Where(r => r.TaskId == taskId) + .OrderByDescending(r => r.RunNumber) + .FirstOrDefaultAsync(ct); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/TaskRunRepository.cs +git commit -m "refactor(data): rewrite TaskRunRepository to EF Core LINQ" +``` + +--- + +### Task 9: Rewrite TaskRepository + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` + +This is the most complex repository. The atomic queue claim stays as raw SQL. + +- [ ] **Step 1: Rewrite TaskRepository to EF Core** + +```csharp +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Repositories; + +public sealed class TaskRepository +{ + private readonly ClaudeDoDbContext _context; + + public TaskRepository(ClaudeDoDbContext context) => _context = context; + + #region CRUD + + public async Task AddAsync(TaskEntity entity, CancellationToken ct = default) + { + _context.Tasks.Add(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default) + { + _context.Tasks.Update(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(string taskId, CancellationToken ct = default) + { + await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct); + } + + public async Task GetByIdAsync(string taskId, CancellationToken ct = default) + { + return await _context.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, ct); + } + + public async Task> GetByListIdAsync(string listId, CancellationToken ct = default) + { + return await _context.Tasks + .Where(t => t.ListId == listId) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(ct); + } + + #endregion + + #region Status transitions + + public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default) + { + await _context.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Running) + .SetProperty(t => t.StartedAt, startedAt), ct); + } + + public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default) + { + await _context.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Done) + .SetProperty(t => t.FinishedAt, finishedAt) + .SetProperty(t => t.Result, result), ct); + } + + public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default) + { + await _context.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Failed) + .SetProperty(t => t.FinishedAt, finishedAt) + .SetProperty(t => t.Result, result), ct); + } + + public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default) + { + await _context.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.LogPath, logPath), ct); + } + + public async Task FlipAllRunningToFailedAsync(CancellationToken ct = default) + { + await _context.Tasks + .Where(t => t.Status == TaskStatus.Running) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Failed) + .SetProperty(t => t.FinishedAt, DateTime.UtcNow), ct); + } + + #endregion + + #region Tags + + public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default) + { + var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct); + var tag = await _context.Tags.FindAsync([tagId], ct); + if (tag is not null && !task.Tags.Any(t => t.Id == tagId)) + { + task.Tags.Add(tag); + await _context.SaveChangesAsync(ct); + } + } + + public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default) + { + var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct); + var tag = task.Tags.FirstOrDefault(t => t.Id == tagId); + if (tag is not null) + { + task.Tags.Remove(tag); + await _context.SaveChangesAsync(ct); + } + } + + public async Task> GetTagsAsync(string taskId, CancellationToken ct = default) + { + return await _context.Tasks + .Where(t => t.Id == taskId) + .SelectMany(t => t.Tags) + .ToListAsync(ct); + } + + public async Task> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default) + { + var taskTags = _context.Tasks + .Where(t => t.Id == taskId) + .SelectMany(t => t.Tags); + var listTags = _context.Tasks + .Where(t => t.Id == taskId) + .SelectMany(t => t.List.Tags); + return await taskTags.Union(listTags).Distinct().ToListAsync(ct); + } + + #endregion + + #region Queue selection + + public async Task GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default) + { + // Atomic queue claim: UPDATE + SELECT in one statement to prevent TOCTOU races. + // Not expressible in LINQ — kept as raw SQL. + var result = await _context.Tasks.FromSqlRaw(""" + UPDATE tasks SET status = 'running' + WHERE id = ( + SELECT t.id FROM tasks t + JOIN task_tags tt ON tt.task_id = t.id + JOIN tags tg ON tg.id = tt.tag_id AND tg.name = 'agent' + WHERE t.status = 'queued' + AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0}) + ORDER BY t.created_at + LIMIT 1 + ) + RETURNING * + """, now.ToString("o")).ToListAsync(ct); + + return result.FirstOrDefault(); + } + + #endregion +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/TaskRepository.cs +git commit -m "refactor(data): rewrite TaskRepository to EF Core LINQ with raw SQL queue claim" +``` + +--- + +### Task 10: Delete Old Infrastructure and Schema + +**Files:** +- Delete: `src/ClaudeDo.Data/SqliteConnectionFactory.cs` +- Delete: `src/ClaudeDo.Data/SchemaInitializer.cs` +- Delete: `schema/schema.sql` + +- [ ] **Step 1: Delete SqliteConnectionFactory.cs** + +```bash +rm src/ClaudeDo.Data/SqliteConnectionFactory.cs +``` + +- [ ] **Step 2: Delete SchemaInitializer.cs** + +```bash +rm src/ClaudeDo.Data/SchemaInitializer.cs +``` + +- [ ] **Step 3: Delete schema.sql** + +```bash +rm schema/schema.sql +``` + +- [ ] **Step 4: Commit** + +```bash +git add -u src/ClaudeDo.Data/SqliteConnectionFactory.cs src/ClaudeDo.Data/SchemaInitializer.cs schema/schema.sql +git commit -m "chore(data): remove raw ADO.NET infrastructure and schema.sql" +``` + +--- + +### Task 11: Update DI Registration — Worker + +**Files:** +- Modify: `src/ClaudeDo.Worker/Program.cs` + +- [ ] **Step 1: Rewrite Worker Program.cs DI section** + +Replace lines 1-28 with EF Core registration. The `SqliteConnectionFactory` and `SchemaInitializer` calls are replaced by `AddDbContext` and `Database.Migrate()`. + +The full updated `Program.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Services; +using Microsoft.EntityFrameworkCore; + +var cfg = WorkerConfig.Load(); + +var builder = WebApplication.CreateBuilder(args); + +// When launched by the Windows SCM, speak the Service Control Protocol so SCM +// doesn't think we crashed (~30s timeout). No-op when running interactively. +builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker"); + +builder.Services.AddSingleton(cfg); + +// EF Core DbContext — scoped lifetime (one context per scope/request). +builder.Services.AddDbContext(opt => + opt.UseSqlite($"Data Source={cfg.DbPath}")); + +// Repositories — scoped (they depend on the scoped DbContext). +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddHostedService(); +builder.Services.AddSignalR(); + +// Runner stack. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(new AgentFileService(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".todo-app", "agents"))); + +// QueueService: singleton + hosted service (same instance). +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +// Loopback-only bind. Firewall is irrelevant for 127.0.0.1. +builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}"); + +var app = builder.Build(); + +// Apply EF Core migrations at startup. +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); +} + +app.MapHub("/hub"); + +app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})", + cfg.SignalRPort, cfg.DbPath); + +app.Run(); +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Worker/Program.cs +git commit -m "refactor(worker): switch DI from raw ADO.NET to EF Core" +``` + +--- + +### Task 12: Update DI Registration — App + +**Files:** +- Modify: `src/ClaudeDo.App/Program.cs` + +- [ ] **Step 1: Rewrite App Program.cs DI section** + +The App uses `ServiceCollection` directly (no WebApplication builder). ViewModels are singletons that need database access — they take `IDbContextFactory` instead of repositories. + +```csharp +using Avalonia; +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Ui; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System; + +namespace ClaudeDo.App; + +sealed class Program +{ + [STAThread] + public static void Main(string[] args) + { + var services = BuildServices(); + App.Services = services; + + // Apply EF Core migrations at startup. + using (var scope = services.CreateScope()) + { + scope.ServiceProvider.GetRequiredService().Database.Migrate(); + } + + try + { + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } + finally + { + // Dispose the container so WorkerClient.DisposeAsync runs — + // cancels the retry loop and closes the SignalR connection cleanly + // instead of abandoning it. + try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); } + catch { /* best effort on shutdown */ } + } + } + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() +#if DEBUG + .WithDeveloperTools() +#endif + .WithInterFont() + .LogToTrace(); + + private static ServiceProvider BuildServices() + { + var settings = AppSettings.Load(); + var dbPath = Paths.Expand(settings.DbPath); + + var sc = new ServiceCollection(); + + // Infrastructure + sc.AddSingleton(settings); + + // EF Core — register both the factory (for singletons) and scoped context. + sc.AddDbContextFactory(opt => + opt.UseSqlite($"Data Source={dbPath}")); + sc.AddScoped(sp => + sp.GetRequiredService>().CreateDbContext()); + + // Repositories — scoped. + sc.AddScoped(); + sc.AddScoped(); + sc.AddScoped(); + sc.AddScoped(); + sc.AddScoped(); + + // Services + sc.AddSingleton(); + sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService().SignalRUrl)); + + // ViewModels — singletons that use IDbContextFactory for on-demand DB access. + sc.AddTransient(); + sc.AddTransient(); + sc.AddSingleton(); + sc.AddSingleton>(sp => + sp.GetRequiredService>()); + + // ViewModel factory registrations — these need to be updated in Task 13 + // to take IDbContextFactory instead of repositories directly. + // For now, leave them unchanged — they won't compile until Task 13. + + return sc.BuildServiceProvider(); + } +} +``` + +Note: This file will NOT compile until Task 13 updates the ViewModels. That is expected. + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.App/Program.cs +git commit -m "refactor(app): switch DI from raw ADO.NET to EF Core" +``` + +--- + +### Task 13: Update ViewModel and Worker Service Consumers + +All singleton ViewModels and Worker services currently take repository instances directly. Since repositories are now scoped, these consumers must take `IDbContextFactory` and create a fresh context + repositories per operation. + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs` +- Modify: `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs` +- Modify: `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` +- Modify: `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs` +- Modify: `src/ClaudeDo.Worker/Services/QueueService.cs` +- Modify: `src/ClaudeDo.Worker/Services/StaleTaskRecovery.cs` +- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` +- Modify: `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` + +- [ ] **Step 1: Update each consumer** + +For each consumer, apply this pattern: + +**Before (example — QueueService):** +```csharp +private readonly TaskRepository _taskRepo; +public QueueService(TaskRepository taskRepo, ...) { _taskRepo = taskRepo; ... } + +// In method: +var task = await _taskRepo.GetNextQueuedAgentTaskAsync(now, ct); +``` + +**After:** +```csharp +private readonly IDbContextFactory _dbFactory; +public QueueService(IDbContextFactory dbFactory, ...) { _dbFactory = dbFactory; ... } + +// In method: +using var context = _dbFactory.CreateDbContext(); +var taskRepo = new TaskRepository(context); +var task = await taskRepo.GetNextQueuedAgentTaskAsync(now, ct); +``` + +Apply this to every file listed above. Each method that touches the DB creates a context and the repositories it needs. The context is disposed at method end. + +**Consumer → repositories used:** + +| Consumer | Repositories | +|---|---| +| `MainWindowViewModel` | `ListRepository` | +| `TaskDetailViewModel` | `TaskRepository`, `WorktreeRepository`, `ListRepository`, `TagRepository`, `SubtaskRepository` | +| `TaskListViewModel` | `TaskRepository`, `TagRepository`, `ListRepository` | +| `TaskEditorViewModel` | `SubtaskRepository` | +| `QueueService` | `TaskRepository` | +| `StaleTaskRecovery` | `TaskRepository` | +| `TaskRunner` | `TaskRepository`, `TaskRunRepository`, `ListRepository`, `WorktreeRepository`, `SubtaskRepository` | +| `WorktreeManager` | `WorktreeRepository` | + +For each file: +1. Replace repository fields with a single `IDbContextFactory _dbFactory` field. +2. Update constructor to take `IDbContextFactory`. +3. In each method that uses repos: `using var context = _dbFactory.CreateDbContext();` then instantiate needed repos. + +- [ ] **Step 2: Update App Program.cs ViewModel registrations** + +Now that ViewModels take `IDbContextFactory` instead of repos, simplify the registrations. The long factory lambdas that manually resolved repos can be replaced with simpler registrations since ViewModels now only need `IDbContextFactory` and non-repo services: + +```csharp + // ViewModels + sc.AddTransient(); + sc.AddTransient(); + sc.AddSingleton(); + sc.AddSingleton(); + sc.AddSingleton(); + sc.AddSingleton(); +``` + +If the constructors still take non-repo dependencies (GitService, WorkerClient, StatusBarViewModel, etc.), keep those in the constructor and let DI resolve them naturally. + +- [ ] **Step 3: Update Worker Program.cs singleton services** + +Worker singleton services (QueueService, TaskRunner, WorktreeManager) now take `IDbContextFactory` instead of repos. Add factory registration and update service registrations: + +Add to Worker Program.cs after `AddDbContext`: +```csharp +builder.Services.AddDbContextFactory(opt => + opt.UseSqlite($"Data Source={cfg.DbPath}")); +``` + +Singleton services will resolve `IDbContextFactory` from DI and create scoped contexts per operation. + +- [ ] **Step 4: Verify the solution builds** + +Run: `dotnet build ClaudeDo.slnx` +Expected: Build succeeds with zero errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/ src/ClaudeDo.Worker/Services/ src/ClaudeDo.Worker/Runner/ src/ClaudeDo.App/Program.cs src/ClaudeDo.Worker/Program.cs +git commit -m "refactor(ui,worker): switch consumers from direct repos to IDbContextFactory" +``` + +--- + +### Task 14: Generate Initial EF Core Migration + +**Files:** +- Create: `src/ClaudeDo.Data/Migrations/` (generated) + +- [ ] **Step 1: Install EF Core tools if needed** + +Run: `dotnet tool list -g | grep dotnet-ef || dotnet tool install --global dotnet-ef --version 8.0.11` + +- [ ] **Step 2: Generate the initial migration** + +Run from the repo root: + +```bash +dotnet ef migrations add InitialCreate --project src/ClaudeDo.Data --startup-project src/ClaudeDo.Worker +``` + +Expected: Creates `src/ClaudeDo.Data/Migrations/` with `*_InitialCreate.cs` and `ClaudeDoDbContextModelSnapshot.cs`. + +- [ ] **Step 3: Add existing-database compatibility shim** + +Edit the generated `*_InitialCreate.cs` `Up` method. Wrap all `CreateTable` calls in a check for existing tables. Add this at the very start of `Up`: + +```csharp +protected override void Up(MigrationBuilder migrationBuilder) +{ + // If upgrading from the old schema.sql-based setup, tables already exist. + // Check for the 'lists' table as a sentinel — if it exists, skip DDL and + // just ensure the migration is recorded. + migrationBuilder.Sql(""" + CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( + "MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY, + "ProductVersion" TEXT NOT NULL + ); + """); + + // The rest of the migration uses IF NOT EXISTS for safety. + // ... (keep the generated code but add IF NOT EXISTS to each CreateTable) +} +``` + +Alternatively, since SQLite `CREATE TABLE IF NOT EXISTS` is supported, prefix each generated `CREATE TABLE` statement. The simplest approach: in `Up`, replace each `migrationBuilder.CreateTable(...)` with equivalent `migrationBuilder.Sql("CREATE TABLE IF NOT EXISTS ...")` calls. This is a one-time adaptation. + +- [ ] **Step 4: Verify migration applies to a fresh database** + +```bash +rm -f /tmp/test_migration.db +dotnet ef database update --project src/ClaudeDo.Data --startup-project src/ClaudeDo.Worker -- --DbPath /tmp/test_migration.db +``` + +Or simply run the Worker pointed at a new DB path and check it starts. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/Migrations/ +git commit -m "feat(data): add InitialCreate EF Core migration with existing-DB compat shim" +``` + +--- + +### Task 15: Update Test Infrastructure + +**Files:** +- Modify: `tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs` (and any other test files that use DbFixture) + +- [ ] **Step 1: Rewrite DbFixture to use EF Core** + +```csharp +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.Tests.Infrastructure; + +public sealed class DbFixture : IDisposable +{ + public string DbPath { get; } + public DbContextOptions Options { get; } + + public DbFixture() + { + DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db"); + Options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={DbPath}") + .Options; + + using var context = CreateContext(); + context.Database.Migrate(); + } + + public ClaudeDoDbContext CreateContext() => new(Options); + + public void Dispose() + { + try { File.Delete(DbPath); } catch { /* best effort */ } + try { File.Delete(DbPath + "-wal"); } catch { } + try { File.Delete(DbPath + "-shm"); } catch { } + } +} +``` + +- [ ] **Step 2: Update WorktreeManagerTests and other test files** + +Replace `new ListRepository(db.Factory)` with `new ListRepository(db.CreateContext())` etc. For example in WorktreeManagerTests `CreateManagerAsync`: + +```csharp +private async Task<(WorktreeManager mgr, WorktreeRepository wtRepo)> CreateManagerAsync( + TaskEntity task, ListEntity list, string strategy = "sibling", string? centralRoot = null) +{ + var db = new DbFixture(); + _dbFixtures.Add(db); + + var context = db.CreateContext(); + var listRepo = new ListRepository(context); + var taskRepo = new TaskRepository(context); + var wtRepo = new WorktreeRepository(context); + // ... seed and create manager as before, but using context-based repos +``` + +Apply the same pattern to all test files that use `DbFixture.Factory` to construct repositories. + +- [ ] **Step 3: Run tests** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests` +Expected: All existing tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/ +git commit -m "refactor(tests): switch test infrastructure to EF Core DbContext" +``` + +--- + +### Task 16: Full Build and Test Verification + +- [ ] **Step 1: Clean build** + +Run: `dotnet build ClaudeDo.slnx --no-incremental` +Expected: Build succeeds with zero errors. + +- [ ] **Step 2: Run all tests** + +Run: `dotnet test ClaudeDo.slnx` +Expected: All tests pass. + +- [ ] **Step 3: Smoke test — start Worker** + +Run the Worker and verify it starts, creates/migrates the DB, and logs the listening message. + +- [ ] **Step 4: Smoke test — start App** + +Run the App and verify it starts, connects to Worker (if running), and the UI loads. + +- [ ] **Step 5: Verify no references to old types remain** + +```bash +grep -rn "SqliteConnectionFactory\|SchemaInitializer\|Microsoft\.Data\.Sqlite\|using Microsoft\.Data\.Sqlite" src/ tests/ --include="*.cs" --include="*.csproj" +``` + +Expected: Zero matches. + +- [ ] **Step 6: Final commit if any cleanup was needed** + +```bash +git add -A +git commit -m "chore(data): final cleanup after EF Core migration" +``` + +--- + +### Task 17: Update Documentation + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `src/ClaudeDo.Data/CLAUDE.md` + +- [ ] **Step 1: Update project CLAUDE.md** + +Update the Tech Stack, Conventions, and Key Paths sections to reflect EF Core: +- Replace "SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM" with "SQLite (WAL mode) via Entity Framework Core + Microsoft.EntityFrameworkCore.Sqlite" +- Remove references to `schema/schema.sql`, `SqliteConnectionFactory`, `SchemaInitializer` +- Update repository description to mention LINQ instead of raw SQL +- Add note about `IDbContextFactory` pattern for singleton consumers + +- [ ] **Step 2: Update ClaudeDo.Data/CLAUDE.md** + +Rewrite the Infrastructure and Repositories sections: +- Infrastructure: `ClaudeDoDbContext` replaces `SqliteConnectionFactory` + `SchemaInitializer` +- Repositories: "All repositories use EF Core LINQ. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim." +- Remove Conventions about `ToDb()`/`FromDb()` and `DBNull.Value` +- Add Conventions about `IEntityTypeConfiguration` in `Configuration/` folder + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md src/ClaudeDo.Data/CLAUDE.md +git commit -m "docs: update CLAUDE.md files for EF Core migration" +``` diff --git a/docs/superpowers/specs/2026-04-16-efcore-migration-design.md b/docs/superpowers/specs/2026-04-16-efcore-migration-design.md new file mode 100644 index 0000000..0bba95e --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-efcore-migration-design.md @@ -0,0 +1,253 @@ +# EF Core Migration Design + +Replace the raw ADO.NET / Microsoft.Data.Sqlite data layer with Entity Framework Core and LINQ queries. + +## Motivation + +- Developer ergonomics: raw SQL is tedious to write and maintain; LINQ enables faster iteration. +- Maintainability: the ad-hoc migration approach (ALTER TABLE with error-code catching) and manual DBNull/enum mapping are a liability as the schema grows. EF Core provides proper migration versioning, value converters, and change tracking. + +## Decision Summary + +| Decision | Choice | +|---|---| +| Approach | Big bang — rewrite all 6 repositories at once | +| Migration strategy | Fresh start — EF Core owns the schema, drop schema.sql | +| DbContext sharing | Single shared `ClaudeDoDbContext` in ClaudeDo.Data | +| Configuration style | Fluent API only, clean POCO models | +| Atomic queue claim | Kept as `FromSqlRaw` — not expressible in LINQ | + +--- + +## 1. DbContext and Entity Configuration + +### ClaudeDoDbContext + +A single `ClaudeDoDbContext` in `ClaudeDo.Data` with DbSets for all entities: + +```csharp +public class ClaudeDoDbContext : DbContext +{ + public DbSet Tasks => Set(); + public DbSet Lists => Set(); + public DbSet Tags => Set(); + public DbSet ListConfigs => Set(); + public DbSet Worktrees => Set(); + public DbSet TaskRuns => Set(); + public DbSet Subtasks => Set(); +} +``` + +### Entity-to-Table Mapping + +| Entity | Table | Key | Notes | +|---|---|---|---| +| `TaskEntity` | `tasks` | `Id` (TEXT) | Nav to List, Tags, Worktree, Runs, Subtasks | +| `ListEntity` | `lists` | `Id` (TEXT) | Nav to Tasks, Tags, Config | +| `TagEntity` | `tags` | `Id` (INTEGER auto) | Nav to Lists, Tasks (both M:N) | +| `ListConfigEntity` | `list_config` | `ListId` (TEXT) | 1:1 owned by List | +| `WorktreeEntity` | `worktrees` | `TaskId` (TEXT) | 1:1 owned by Task | +| `TaskRunEntity` | `task_runs` | `Id` (TEXT) | FK to Task | +| `SubtaskEntity` | `subtasks` | `Id` (TEXT) | FK to Task | + +### Navigation Properties Added to Models + +```csharp +// TaskEntity gains: +public ListEntity List { get; set; } +public WorktreeEntity? Worktree { get; set; } +public ICollection Tags { get; set; } +public ICollection Runs { get; set; } +public ICollection Subtasks { get; set; } + +// ListEntity gains: +public ListConfigEntity? Config { get; set; } +public ICollection Tasks { get; set; } +public ICollection Tags { get; set; } + +// TagEntity gains: +public ICollection Lists { get; set; } +public ICollection Tasks { get; set; } +``` + +### Enum Handling + +EF Core `ValueConverter` for `TaskStatus` and `WorktreeState`, storing the same lowercase strings (`"manual"`, `"active"`, etc.) for database compatibility. The `ToDb`/`FromDb` methods in repositories are removed. + +### Junction Tables + +`list_tags` and `task_tags` are configured as implicit join tables via `.UsingEntity()` in Fluent API — no explicit junction entity classes needed. + +### Fluent Configuration + +Each entity gets its own `IEntityTypeConfiguration` class in a `Configuration/` folder within `ClaudeDo.Data`. + +--- + +## 2. Migration Strategy + +### Fresh Start + +- `schema.sql` and `SchemaInitializer` are deleted. +- An initial EF Core migration (`InitialCreate`) is generated from the DbContext model, producing the full schema (all 8 tables, indexes, foreign keys, check constraints). +- EF's `__EFMigrationsHistory` table tracks applied migrations. + +### Startup + +Both App and Worker call `context.Database.Migrate()` at startup instead of `SchemaInitializer.Apply()`. This is idempotent. + +### Existing Database Compatibility + +For users who already have a database created by `schema.sql`, the initial migration must handle the schema already existing. On startup, if the `lists` table exists but `__EFMigrationsHistory` does not, insert the initial migration record into `__EFMigrationsHistory` so EF skips it. + +### Seed Data + +The `"agent"` and `"manual"` tags move into `OnModelCreating` via `HasData()`: + +```csharp +modelBuilder.Entity().HasData( + new TagEntity { Id = 1, Name = "agent" }, + new TagEntity { Id = 2, Name = "manual" }); +``` + +### Ad-hoc Migrations Removed + +The 3 manual `ALTER TABLE` statements (model, system_prompt, agent_path on tasks) become part of the initial migration since they're already in the model. The manual `ApplyMigrations()` method is deleted. + +--- + +## 3. Repository Rewrite + +All 6 repositories are rewritten to use `ClaudeDoDbContext` and LINQ. + +### Per-Repository Changes + +| Repository | After EF Core | +|---|---| +| `TagRepository` | LINQ queries. `GetOrCreateAsync` uses `FirstOrDefaultAsync` + `Add` + `SaveChangesAsync`. Static `SqliteConnection` overload removed. | +| `SubtaskRepository` | Straightforward LINQ CRUD, `.OrderBy(s => s.OrderNum)`. | +| `WorktreeRepository` | LINQ CRUD. State update becomes property set + `SaveChangesAsync`. | +| `ListRepository` | LINQ CRUD. Tag management via `.Tags` navigation property. Config upsert via `List.Config` navigation. | +| `TaskRunRepository` | LINQ CRUD. Latest = `.OrderByDescending(r => r.RunNumber).FirstOrDefaultAsync()`. | +| `TaskRepository` | See special cases below. | + +### TaskRepository Special Cases + +**Atomic queue claim** (`GetNextQueuedAgentTaskAsync`): kept as `FromSqlRaw` / `ExecuteSqlRawAsync`. The `UPDATE ... WHERE id = (SELECT ...) RETURNING` is not expressible in LINQ and the atomicity guarantee matters. + +**Effective tags** (`GetEffectiveTagsAsync`): LINQ via navigation properties: + +```csharp +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); +``` + +**FlipAllRunningToFailed**: EF Core 7+ bulk update: + +```csharp +await context.Tasks + .Where(t => t.Status == TaskStatus.Running) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Failed), ct); +``` + +**Status transitions** (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`): property updates + `SaveChangesAsync`. + +### Removed Code + +- `SqliteConnectionFactory.cs` +- `SchemaInitializer.cs` +- `schema/schema.sql` +- All `ToDb`/`FromDb` enum mapping methods +- All manual `DBNull.Value` handling +- `BindTask` helper methods + +--- + +## 4. Package Changes and DI Registration + +### ClaudeDo.Data.csproj + +- Remove: `Microsoft.Data.Sqlite` +- Remove: embedded resource for `schema.sql` +- Add: `Microsoft.EntityFrameworkCore.Sqlite` +- Add: `Microsoft.EntityFrameworkCore.Design` (`PrivateAssets="all"`) + +### ClaudeDo.Worker.Tests.csproj + +- Remove: `Microsoft.Data.Sqlite` +- Add: `Microsoft.EntityFrameworkCore.Sqlite` + +### App DI (Program.cs) + +```csharp +// Replace SqliteConnectionFactory + singleton repos with: +sc.AddDbContextFactory(opt => + opt.UseSqlite($"Data Source={dbPath}")); +sc.AddScoped(sp => + sp.GetRequiredService>().CreateDbContext()); +sc.AddScoped(); +sc.AddScoped(); +sc.AddScoped(); +sc.AddScoped(); +sc.AddScoped(); +sc.AddScoped(); + +// Migrate at startup: +using var initScope = services.CreateScope(); +initScope.ServiceProvider.GetRequiredService().Database.Migrate(); +``` + +ViewModels are singletons that currently take repositories as constructor parameters. Since repositories become scoped, ViewModels switch to taking `IDbContextFactory` and create a fresh context (+ repositories) per operation. Each ViewModel method that touches data does: `using var context = _factory.CreateDbContext();` then constructs or resolves the needed repository with that context. This mirrors the current connection-per-call pattern. + +### Worker DI (Program.cs) + +```csharp +builder.Services.AddDbContext(opt => + opt.UseSqlite($"Data Source={cfg.DbPath}")); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Migrate at startup after build: +using var scope = app.Services.CreateScope(); +scope.ServiceProvider.GetRequiredService().Database.Migrate(); +``` + +Worker has request scopes via SignalR hub invocations, so scoped registration works naturally. + +--- + +## 5. Test Infrastructure + +### DbFixture + +`DbFixture` is rewritten as an EF Core fixture: + +- Creates a temp SQLite file per test class. +- Builds `DbContextOptions` with `UseSqlite`. +- Calls `context.Database.Migrate()` to apply the schema (also tests that migrations work). +- Exposes a `CreateContext()` method so each test gets a fresh context instance (avoids change-tracker bleed). + +Tests construct repositories by passing in a fresh context from the fixture. + +No mocking — tests keep hitting real SQLite, same philosophy as today. + +--- + +## 6. Risk and Mitigation + +| Risk | Mitigation | +|---|---| +| Big-bang rewrite touches nearly every file in ClaudeDo.Data | Existing tests are the safety net — all must pass after migration | +| Existing databases with schema from schema.sql | Compatibility shim: detect existing tables, mark initial migration as applied | +| Atomic queue claim semantics change | Kept as raw SQL via `FromSqlRaw` | +| Scoped lifetime vs. singleton ViewModels | `IDbContextFactory` provides on-demand contexts | +| EF change tracker overhead vs. raw ADO.NET | Negligible for this workload size; use `AsNoTracking()` for read-only queries | diff --git a/schema/schema.sql b/schema/schema.sql deleted file mode 100644 index 1e75c85..0000000 --- a/schema/schema.sql +++ /dev/null @@ -1,100 +0,0 @@ --- ClaudeDo SQLite schema (single source of truth, 3NF) --- Applied by Worker on first startup. WAL mode is set via PRAGMA after open. - -PRAGMA foreign_keys = ON; - -CREATE TABLE IF NOT EXISTS lists ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - working_dir TEXT NULL, - default_commit_type TEXT NOT NULL DEFAULT 'chore' -); - -CREATE TABLE IF NOT EXISTS tasks ( - id TEXT PRIMARY KEY, - list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT NULL, - status TEXT NOT NULL CHECK (status IN ('manual','queued','running','done','failed')), - scheduled_for TIMESTAMP NULL, - result TEXT NULL, - log_path TEXT NULL, - created_at TIMESTAMP NOT NULL, - started_at TIMESTAMP NULL, - finished_at TIMESTAMP NULL, - commit_type TEXT NOT NULL DEFAULT 'chore' -); - -CREATE INDEX IF NOT EXISTS idx_tasks_list_id ON tasks(list_id); -CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); - -CREATE TABLE IF NOT EXISTS tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE -); - -CREATE TABLE IF NOT EXISTS list_tags ( - list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (list_id, tag_id) -); - -CREATE TABLE IF NOT EXISTS task_tags ( - task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (task_id, tag_id) -); - -CREATE TABLE IF NOT EXISTS list_config ( - list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE, - model TEXT NULL, - system_prompt TEXT NULL, - agent_path TEXT NULL -); - -CREATE TABLE IF NOT EXISTS worktrees ( - task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE, - path TEXT NOT NULL, - branch_name TEXT NOT NULL, - base_commit TEXT NOT NULL, - head_commit TEXT NULL, - diff_stat TEXT NULL, - state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active','merged','discarded','kept')), - created_at TIMESTAMP NOT NULL -); - -CREATE TABLE IF NOT EXISTS task_runs ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - run_number INTEGER NOT NULL, - session_id TEXT NULL, - is_retry INTEGER NOT NULL DEFAULT 0, - prompt TEXT NOT NULL, - result_markdown TEXT NULL, - structured_output TEXT NULL, - error_markdown TEXT NULL, - exit_code INTEGER NULL, - turn_count INTEGER NULL, - tokens_in INTEGER NULL, - tokens_out INTEGER NULL, - log_path TEXT NULL, - started_at TIMESTAMP NULL, - finished_at TIMESTAMP NULL -); - -CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id); - -CREATE TABLE IF NOT EXISTS subtasks ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - title TEXT NOT NULL, - completed INTEGER NOT NULL DEFAULT 0, - order_num INTEGER NOT NULL, - created_at TIMESTAMP NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_subtasks_task_id ON subtasks(task_id); - --- Seed: minimal tag set (ignored if already present) -INSERT OR IGNORE INTO tags (name) VALUES ('agent'); -INSERT OR IGNORE INTO tags (name) VALUES ('manual'); diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index bc67d52..68dbd87 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -5,6 +5,7 @@ using ClaudeDo.Data.Repositories; using ClaudeDo.Ui; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using System; @@ -18,9 +19,11 @@ sealed class Program var services = BuildServices(); App.Services = services; - // Ensure DB schema exists - var factory = services.GetRequiredService(); - SchemaInitializer.Apply(factory); + using (var scope = services.CreateScope()) + { + ClaudeDoDbContext.MigrateAndConfigure( + scope.ServiceProvider.GetRequiredService()); + } try { @@ -55,14 +58,10 @@ sealed class Program // Infrastructure sc.AddSingleton(settings); - sc.AddSingleton(new SqliteConnectionFactory(dbPath)); - - // Repositories - sc.AddSingleton(); - sc.AddSingleton(); - sc.AddSingleton(); - sc.AddSingleton(); - sc.AddSingleton(); + sc.AddDbContextFactory(opt => + opt.UseSqlite($"Data Source={dbPath}")); + sc.AddScoped(sp => + sp.GetRequiredService>().CreateDbContext()); // Services sc.AddSingleton(); @@ -72,30 +71,21 @@ sealed class Program sc.AddTransient(); sc.AddTransient(); sc.AddSingleton(); - sc.AddSingleton(sp => new TaskDetailViewModel( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService())); + sc.AddSingleton(); sc.AddSingleton(sp => { - var taskRepo = sp.GetRequiredService(); - var tagRepo = sp.GetRequiredService(); - var listRepo = sp.GetRequiredService(); + var dbFactory = sp.GetRequiredService>(); var worker = sp.GetRequiredService(); var statusBar = sp.GetRequiredService(); return new TaskListViewModel( - taskRepo, tagRepo, listRepo, worker, + dbFactory, worker, () => sp.GetRequiredService(), msg => statusBar.ShowMessage(msg)); }); sc.AddSingleton(sp => { return new MainWindowViewModel( - sp.GetRequiredService(), + sp.GetRequiredService>(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 6720add..378c405 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -11,7 +11,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio ## Repositories -All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each method opens its own connection — no Unit of Work. +All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim. - **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync` - **ListRepository** — CRUD, tag junction management @@ -20,8 +20,8 @@ All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each met ## Infrastructure -- **SqliteConnectionFactory** — creates connections, applies WAL mode once, enforces foreign keys via PRAGMA -- **SchemaInitializer** — applies embedded `schema/schema.sql` idempotently (IF NOT EXISTS, INSERT OR IGNORE) +- **ClaudeDoDbContext** — EF Core DbContext; configured with WAL mode and foreign keys via `UseSqlite` options +- **IDbContextFactory** — registered in DI; used by singleton consumers (e.g. Worker hosted service) - **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app` - **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl) @@ -31,11 +31,11 @@ All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each met ## Schema -6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. See `schema/schema.sql`. Seed data: tags "agent" and "manual". +6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual". ## Conventions -- Enum <-> string mapping via explicit `ToDb()`/`FromDb()` static methods on each enum +- Enum <-> string mapping via EF Core `ValueConverter` (configured in `IEntityTypeConfiguration`) +- Entity configurations live in the `Configuration/` folder - Primary keys are `init`-only strings (GUIDs assigned at creation) -- Nullable fields use `DBNull.Value` checks - All methods are async with CancellationToken where applicable diff --git a/src/ClaudeDo.Data/ClaudeDo.Data.csproj b/src/ClaudeDo.Data/ClaudeDo.Data.csproj index 6bb4de7..ec9a23e 100644 --- a/src/ClaudeDo.Data/ClaudeDo.Data.csproj +++ b/src/ClaudeDo.Data/ClaudeDo.Data.csproj @@ -7,11 +7,11 @@ - - - - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/ClaudeDo.Data/ClaudeDoDbContext.cs b/src/ClaudeDo.Data/ClaudeDoDbContext.cs new file mode 100644 index 0000000..661e136 --- /dev/null +++ b/src/ClaudeDo.Data/ClaudeDoDbContext.cs @@ -0,0 +1,64 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; + +namespace ClaudeDo.Data; + +public class ClaudeDoDbContext : DbContext +{ + public ClaudeDoDbContext(DbContextOptions options) : base(options) { } + + public DbSet Tasks => Set(); + public DbSet Lists => Set(); + public DbSet Tags => Set(); + public DbSet ListConfigs => Set(); + public DbSet Worktrees => Set(); + public DbSet TaskRuns => Set(); + public DbSet Subtasks => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly); + } + + /// + /// Applies EF Core migrations and sets WAL mode. Safe for both fresh and existing databases. + /// Existing databases (created by the old schema.sql) have their tables but no + /// __EFMigrationsHistory — this method detects that case and baselines the initial + /// migration so EF skips re-creating tables that already exist. + /// + public static void MigrateAndConfigure(ClaudeDoDbContext db) + { + // If the 'lists' table exists but __EFMigrationsHistory does not, + // this is a pre-EF database. Baseline the InitialCreate migration. + var conn = db.Database.GetDbConnection(); + conn.Open(); + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'"; + var hasLists = Convert.ToInt64(cmd.ExecuteScalar()) > 0; + + cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'"; + var hasHistory = Convert.ToInt64(cmd.ExecuteScalar()) > 0; + + if (hasLists && !hasHistory) + { + // Create the history table and mark InitialCreate as applied. + cmd.CommandText = """ + CREATE TABLE "__EFMigrationsHistory" ( + "MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY, + "ProductVersion" TEXT NOT NULL + ); + INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ('20260416064948_InitialCreate', '8.0.11'); + """; + cmd.ExecuteNonQuery(); + } + } + conn.Close(); + + db.Database.Migrate(); + db.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL"); + } +} diff --git a/src/ClaudeDo.Data/ClaudeDoDbContextFactory.cs b/src/ClaudeDo.Data/ClaudeDoDbContextFactory.cs new file mode 100644 index 0000000..2c83c7e --- /dev/null +++ b/src/ClaudeDo.Data/ClaudeDoDbContextFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace ClaudeDo.Data; + +public sealed class ClaudeDoDbContextFactory : IDesignTimeDbContextFactory +{ + public ClaudeDoDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=design-time.db") + .Options; + return new ClaudeDoDbContext(options); + } +} diff --git a/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs new file mode 100644 index 0000000..97d3451 --- /dev/null +++ b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs @@ -0,0 +1,19 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class ListConfigEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("list_config"); + + builder.HasKey(c => c.ListId); + builder.Property(c => c.ListId).HasColumnName("list_id"); + builder.Property(c => c.Model).HasColumnName("model"); + builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt"); + builder.Property(c => c.AgentPath).HasColumnName("agent_path"); + } +} diff --git a/src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs new file mode 100644 index 0000000..17e5ebc --- /dev/null +++ b/src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs @@ -0,0 +1,36 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class ListEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("lists"); + + builder.HasKey(l => l.Id); + builder.Property(l => l.Id).HasColumnName("id"); + builder.Property(l => l.Name).HasColumnName("name").IsRequired(); + builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired(); + builder.Property(l => l.WorkingDir).HasColumnName("working_dir"); + builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore"); + + builder.HasOne(l => l.Config) + .WithOne(c => c.List) + .HasForeignKey(c => c.ListId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(l => l.Tags) + .WithMany(tag => tag.Lists) + .UsingEntity("list_tags", + l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade), + r => r.HasOne(typeof(ListEntity)).WithMany().HasForeignKey("list_id").OnDelete(DeleteBehavior.Cascade), + j => + { + j.HasKey("list_id", "tag_id"); + j.ToTable("list_tags"); + }); + } +} diff --git a/src/ClaudeDo.Data/Configuration/SubtaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/SubtaskEntityConfiguration.cs new file mode 100644 index 0000000..23ac819 --- /dev/null +++ b/src/ClaudeDo.Data/Configuration/SubtaskEntityConfiguration.cs @@ -0,0 +1,28 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class SubtaskEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("subtasks"); + + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).HasColumnName("id"); + builder.Property(s => s.TaskId).HasColumnName("task_id").IsRequired(); + builder.Property(s => s.Title).HasColumnName("title").IsRequired(); + builder.Property(s => s.Completed).HasColumnName("completed").IsRequired().HasDefaultValue(false); + builder.Property(s => s.OrderNum).HasColumnName("order_num").IsRequired(); + builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.HasOne(s => s.Task) + .WithMany(t => t.Subtasks) + .HasForeignKey(s => s.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(s => s.TaskId).HasDatabaseName("idx_subtasks_task_id"); + } +} diff --git a/src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs new file mode 100644 index 0000000..066b0ec --- /dev/null +++ b/src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs @@ -0,0 +1,22 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class TagEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tags"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).HasColumnName("id").ValueGeneratedOnAdd(); + builder.Property(t => t.Name).HasColumnName("name").IsRequired(); + builder.HasIndex(t => t.Name).IsUnique(); + + builder.HasData( + new TagEntity { Id = 1, Name = "agent" }, + new TagEntity { Id = 2, Name = "manual" }); + } +} diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs new file mode 100644 index 0000000..5774c2a --- /dev/null +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -0,0 +1,75 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Configuration; + +public class TaskEntityConfiguration : IEntityTypeConfiguration +{ + private static string StatusToString(TaskStatus v) + => v == TaskStatus.Manual ? "manual" + : v == TaskStatus.Queued ? "queued" + : v == TaskStatus.Running ? "running" + : v == TaskStatus.Done ? "done" + : v == TaskStatus.Failed ? "failed" + : throw new ArgumentOutOfRangeException(nameof(v)); + + private static TaskStatus StatusFromString(string v) + => v == "manual" ? TaskStatus.Manual + : v == "queued" ? TaskStatus.Queued + : v == "running" ? TaskStatus.Running + : v == "done" ? TaskStatus.Done + : v == "failed" ? TaskStatus.Failed + : throw new ArgumentOutOfRangeException(nameof(v)); + + private static readonly ValueConverter StatusConverter = + new(v => StatusToString(v), v => StatusFromString(v)); + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tasks"); + + builder.HasKey(t => t.Id); + builder.Property(t => t.Id).HasColumnName("id"); + builder.Property(t => t.ListId).HasColumnName("list_id").IsRequired(); + builder.Property(t => t.Title).HasColumnName("title").IsRequired(); + builder.Property(t => t.Description).HasColumnName("description"); + builder.Property(t => t.Status).HasColumnName("status").IsRequired() + .HasConversion(StatusConverter); + builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for"); + builder.Property(t => t.Result).HasColumnName("result"); + builder.Property(t => t.LogPath).HasColumnName("log_path"); + builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired(); + builder.Property(t => t.StartedAt).HasColumnName("started_at"); + builder.Property(t => t.FinishedAt).HasColumnName("finished_at"); + builder.Property(t => t.CommitType).HasColumnName("commit_type").IsRequired().HasDefaultValue("chore"); + builder.Property(t => t.Model).HasColumnName("model"); + builder.Property(t => t.SystemPrompt).HasColumnName("system_prompt"); + builder.Property(t => t.AgentPath).HasColumnName("agent_path"); + + builder.HasOne(t => t.List) + .WithMany(l => l.Tasks) + .HasForeignKey(t => t.ListId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(t => t.Worktree) + .WithOne(w => w.Task) + .HasForeignKey(w => w.TaskId); + + builder.HasMany(t => t.Tags) + .WithMany(tag => tag.Tasks) + .UsingEntity("task_tags", + l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade), + r => r.HasOne(typeof(TaskEntity)).WithMany().HasForeignKey("task_id").OnDelete(DeleteBehavior.Cascade), + j => + { + j.HasKey("task_id", "tag_id"); + j.ToTable("task_tags"); + }); + + builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id"); + builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status"); + } +} diff --git a/src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs new file mode 100644 index 0000000..ecad46b --- /dev/null +++ b/src/ClaudeDo.Data/Configuration/TaskRunEntityConfiguration.cs @@ -0,0 +1,38 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class TaskRunEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("task_runs"); + + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).HasColumnName("id"); + builder.Property(r => r.TaskId).HasColumnName("task_id").IsRequired(); + builder.Property(r => r.RunNumber).HasColumnName("run_number").IsRequired(); + builder.Property(r => r.SessionId).HasColumnName("session_id"); + builder.Property(r => r.IsRetry).HasColumnName("is_retry").IsRequired().HasDefaultValue(false); + builder.Property(r => r.Prompt).HasColumnName("prompt").IsRequired(); + builder.Property(r => r.ResultMarkdown).HasColumnName("result_markdown"); + builder.Property(r => r.StructuredOutputJson).HasColumnName("structured_output"); + builder.Property(r => r.ErrorMarkdown).HasColumnName("error_markdown"); + builder.Property(r => r.ExitCode).HasColumnName("exit_code"); + builder.Property(r => r.TurnCount).HasColumnName("turn_count"); + builder.Property(r => r.TokensIn).HasColumnName("tokens_in"); + builder.Property(r => r.TokensOut).HasColumnName("tokens_out"); + builder.Property(r => r.LogPath).HasColumnName("log_path"); + builder.Property(r => r.StartedAt).HasColumnName("started_at"); + builder.Property(r => r.FinishedAt).HasColumnName("finished_at"); + + builder.HasOne(r => r.Task) + .WithMany(t => t.Runs) + .HasForeignKey(r => r.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(r => r.TaskId).HasDatabaseName("idx_task_runs_task_id"); + } +} diff --git a/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs new file mode 100644 index 0000000..2917f47 --- /dev/null +++ b/src/ClaudeDo.Data/Configuration/WorktreeEntityConfiguration.cs @@ -0,0 +1,43 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ClaudeDo.Data.Configuration; + +public class WorktreeEntityConfiguration : IEntityTypeConfiguration +{ + private static string StateToString(WorktreeState v) + => v == WorktreeState.Active ? "active" + : v == WorktreeState.Merged ? "merged" + : v == WorktreeState.Discarded ? "discarded" + : v == WorktreeState.Kept ? "kept" + : throw new ArgumentOutOfRangeException(nameof(v)); + + private static WorktreeState StateFromString(string v) + => v == "active" ? WorktreeState.Active + : v == "merged" ? WorktreeState.Merged + : v == "discarded" ? WorktreeState.Discarded + : v == "kept" ? WorktreeState.Kept + : throw new ArgumentOutOfRangeException(nameof(v)); + + private static readonly ValueConverter StateConverter = + new(v => StateToString(v), v => StateFromString(v)); + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("worktrees"); + + builder.HasKey(w => w.TaskId); + builder.Property(w => w.TaskId).HasColumnName("task_id"); + builder.Property(w => w.Path).HasColumnName("path").IsRequired(); + builder.Property(w => w.BranchName).HasColumnName("branch_name").IsRequired(); + builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired(); + builder.Property(w => w.HeadCommit).HasColumnName("head_commit"); + builder.Property(w => w.DiffStat).HasColumnName("diff_stat"); + builder.Property(w => w.State).HasColumnName("state").IsRequired() + .HasDefaultValue(WorktreeState.Active) + .HasConversion(StateConverter); + builder.Property(w => w.CreatedAt).HasColumnName("created_at").IsRequired(); + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs b/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs new file mode 100644 index 0000000..301b931 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs @@ -0,0 +1,298 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "lists", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + name = table.Column(type: "TEXT", nullable: false), + created_at = table.Column(type: "TEXT", nullable: false), + working_dir = table.Column(type: "TEXT", nullable: true), + default_commit_type = table.Column(type: "TEXT", nullable: false, defaultValue: "chore") + }, + constraints: table => + { + table.PrimaryKey("PK_lists", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "tags", + columns: table => new + { + id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + name = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_tags", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "list_config", + columns: table => new + { + list_id = table.Column(type: "TEXT", nullable: false), + model = table.Column(type: "TEXT", nullable: true), + system_prompt = table.Column(type: "TEXT", nullable: true), + agent_path = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_list_config", x => x.list_id); + table.ForeignKey( + name: "FK_list_config_lists_list_id", + column: x => x.list_id, + principalTable: "lists", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "tasks", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + list_id = table.Column(type: "TEXT", nullable: false), + title = table.Column(type: "TEXT", nullable: false), + description = table.Column(type: "TEXT", nullable: true), + status = table.Column(type: "TEXT", nullable: false), + scheduled_for = table.Column(type: "TEXT", nullable: true), + result = table.Column(type: "TEXT", nullable: true), + log_path = table.Column(type: "TEXT", nullable: true), + created_at = table.Column(type: "TEXT", nullable: false), + started_at = table.Column(type: "TEXT", nullable: true), + finished_at = table.Column(type: "TEXT", nullable: true), + commit_type = table.Column(type: "TEXT", nullable: false, defaultValue: "chore"), + model = table.Column(type: "TEXT", nullable: true), + system_prompt = table.Column(type: "TEXT", nullable: true), + agent_path = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_tasks", x => x.id); + table.ForeignKey( + name: "FK_tasks_lists_list_id", + column: x => x.list_id, + principalTable: "lists", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "list_tags", + columns: table => new + { + list_id = table.Column(type: "TEXT", nullable: false), + tag_id = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_list_tags", x => new { x.list_id, x.tag_id }); + table.ForeignKey( + name: "FK_list_tags_lists_list_id", + column: x => x.list_id, + principalTable: "lists", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_list_tags_tags_tag_id", + column: x => x.tag_id, + principalTable: "tags", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "subtasks", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + task_id = table.Column(type: "TEXT", nullable: false), + title = table.Column(type: "TEXT", nullable: false), + completed = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + order_num = table.Column(type: "INTEGER", nullable: false), + created_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_subtasks", x => x.id); + table.ForeignKey( + name: "FK_subtasks_tasks_task_id", + column: x => x.task_id, + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "task_runs", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + task_id = table.Column(type: "TEXT", nullable: false), + run_number = table.Column(type: "INTEGER", nullable: false), + session_id = table.Column(type: "TEXT", nullable: true), + is_retry = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + prompt = table.Column(type: "TEXT", nullable: false), + result_markdown = table.Column(type: "TEXT", nullable: true), + structured_output = table.Column(type: "TEXT", nullable: true), + error_markdown = table.Column(type: "TEXT", nullable: true), + exit_code = table.Column(type: "INTEGER", nullable: true), + turn_count = table.Column(type: "INTEGER", nullable: true), + tokens_in = table.Column(type: "INTEGER", nullable: true), + tokens_out = table.Column(type: "INTEGER", nullable: true), + log_path = table.Column(type: "TEXT", nullable: true), + started_at = table.Column(type: "TEXT", nullable: true), + finished_at = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_task_runs", x => x.id); + table.ForeignKey( + name: "FK_task_runs_tasks_task_id", + column: x => x.task_id, + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "task_tags", + columns: table => new + { + task_id = table.Column(type: "TEXT", nullable: false), + tag_id = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_task_tags", x => new { x.task_id, x.tag_id }); + table.ForeignKey( + name: "FK_task_tags_tags_tag_id", + column: x => x.tag_id, + principalTable: "tags", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_task_tags_tasks_task_id", + column: x => x.task_id, + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "worktrees", + columns: table => new + { + task_id = table.Column(type: "TEXT", nullable: false), + path = table.Column(type: "TEXT", nullable: false), + branch_name = table.Column(type: "TEXT", nullable: false), + base_commit = table.Column(type: "TEXT", nullable: false), + head_commit = table.Column(type: "TEXT", nullable: true), + diff_stat = table.Column(type: "TEXT", nullable: true), + state = table.Column(type: "TEXT", nullable: false, defaultValue: "active"), + created_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_worktrees", x => x.task_id); + table.ForeignKey( + name: "FK_worktrees_tasks_task_id", + column: x => x.task_id, + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "tags", + columns: new[] { "id", "name" }, + values: new object[,] + { + { 1L, "agent" }, + { 2L, "manual" } + }); + + migrationBuilder.CreateIndex( + name: "IX_list_tags_tag_id", + table: "list_tags", + column: "tag_id"); + + migrationBuilder.CreateIndex( + name: "idx_subtasks_task_id", + table: "subtasks", + column: "task_id"); + + migrationBuilder.CreateIndex( + name: "IX_tags_name", + table: "tags", + column: "name", + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_task_runs_task_id", + table: "task_runs", + column: "task_id"); + + migrationBuilder.CreateIndex( + name: "IX_task_tags_tag_id", + table: "task_tags", + column: "tag_id"); + + migrationBuilder.CreateIndex( + name: "idx_tasks_list_id", + table: "tasks", + column: "list_id"); + + migrationBuilder.CreateIndex( + name: "idx_tasks_status", + table: "tasks", + column: "status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "list_config"); + + migrationBuilder.DropTable( + name: "list_tags"); + + migrationBuilder.DropTable( + name: "subtasks"); + + migrationBuilder.DropTable( + name: "task_runs"); + + migrationBuilder.DropTable( + name: "task_tags"); + + migrationBuilder.DropTable( + name: "worktrees"); + + migrationBuilder.DropTable( + name: "tags"); + + migrationBuilder.DropTable( + name: "tasks"); + + migrationBuilder.DropTable( + name: "lists"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs new file mode 100644 index 0000000..f5b3351 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -0,0 +1,479 @@ +// +using System; +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + [DbContext(typeof(ClaudeDoDbContext))] + partial class ClaudeDoDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("tags", (string)null); + + b.HasData( + new + { + Id = 1L, + Name = "agent" + }, + new + { + Id = 2L, + Name = "manual" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("list_tags", b => + { + b.Property("list_id") + .HasColumnType("TEXT"); + + b.Property("tag_id") + .HasColumnType("INTEGER"); + + b.HasKey("list_id", "tag_id"); + + b.HasIndex("tag_id"); + + b.ToTable("list_tags", (string)null); + }); + + modelBuilder.Entity("task_tags", b => + { + b.Property("task_id") + .HasColumnType("TEXT"); + + b.Property("tag_id") + .HasColumnType("INTEGER"); + + b.HasKey("task_id", "tag_id"); + + b.HasIndex("tag_id"); + + b.ToTable("task_tags", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("list_tags", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", null) + .WithMany() + .HasForeignKey("list_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TagEntity", null) + .WithMany() + .HasForeignKey("tag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("task_tags", b => + { + b.HasOne("ClaudeDo.Data.Models.TagEntity", null) + .WithMany() + .HasForeignKey("tag_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("task_id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Models/ListConfigEntity.cs b/src/ClaudeDo.Data/Models/ListConfigEntity.cs index 90ba533..98ef1c6 100644 --- a/src/ClaudeDo.Data/Models/ListConfigEntity.cs +++ b/src/ClaudeDo.Data/Models/ListConfigEntity.cs @@ -6,4 +6,7 @@ public sealed class ListConfigEntity public string? Model { get; set; } public string? SystemPrompt { get; set; } public string? AgentPath { get; set; } + + // Navigation property + public ListEntity List { get; set; } = null!; } diff --git a/src/ClaudeDo.Data/Models/ListEntity.cs b/src/ClaudeDo.Data/Models/ListEntity.cs index 5c5809e..020494a 100644 --- a/src/ClaudeDo.Data/Models/ListEntity.cs +++ b/src/ClaudeDo.Data/Models/ListEntity.cs @@ -7,4 +7,9 @@ public sealed class ListEntity public required DateTime CreatedAt { get; init; } public string? WorkingDir { get; set; } public string DefaultCommitType { get; set; } = "chore"; + + // Navigation properties + public ListConfigEntity? Config { get; set; } + public ICollection Tasks { get; set; } = new List(); + public ICollection Tags { get; set; } = new List(); } diff --git a/src/ClaudeDo.Data/Models/SubtaskEntity.cs b/src/ClaudeDo.Data/Models/SubtaskEntity.cs index dbe0c4c..ac10e3f 100644 --- a/src/ClaudeDo.Data/Models/SubtaskEntity.cs +++ b/src/ClaudeDo.Data/Models/SubtaskEntity.cs @@ -8,4 +8,7 @@ public sealed class SubtaskEntity public bool Completed { get; set; } public int OrderNum { get; set; } public required DateTime CreatedAt { get; init; } + + // Navigation property + public TaskEntity Task { get; set; } = null!; } diff --git a/src/ClaudeDo.Data/Models/TagEntity.cs b/src/ClaudeDo.Data/Models/TagEntity.cs index 01c8684..626684a 100644 --- a/src/ClaudeDo.Data/Models/TagEntity.cs +++ b/src/ClaudeDo.Data/Models/TagEntity.cs @@ -4,4 +4,8 @@ public sealed class TagEntity { public long Id { get; init; } public required string Name { get; set; } + + // Navigation properties + public ICollection Lists { get; set; } = new List(); + public ICollection Tasks { get; set; } = new List(); } diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index 55af098..8decb96 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -26,4 +26,11 @@ public sealed class TaskEntity public string? Model { get; set; } public string? SystemPrompt { get; set; } public string? AgentPath { get; set; } + + // Navigation properties + public ListEntity List { get; set; } = null!; + public WorktreeEntity? Worktree { get; set; } + public ICollection Tags { get; set; } = new List(); + public ICollection Runs { get; set; } = new List(); + public ICollection Subtasks { get; set; } = new List(); } diff --git a/src/ClaudeDo.Data/Models/TaskRunEntity.cs b/src/ClaudeDo.Data/Models/TaskRunEntity.cs index 65dc3d2..7913cd9 100644 --- a/src/ClaudeDo.Data/Models/TaskRunEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskRunEntity.cs @@ -18,4 +18,7 @@ public sealed class TaskRunEntity public string? LogPath { get; set; } public DateTime? StartedAt { get; set; } public DateTime? FinishedAt { get; set; } + + // Navigation property + public TaskEntity Task { get; set; } = null!; } diff --git a/src/ClaudeDo.Data/Models/WorktreeEntity.cs b/src/ClaudeDo.Data/Models/WorktreeEntity.cs index 6378b78..9ac26b9 100644 --- a/src/ClaudeDo.Data/Models/WorktreeEntity.cs +++ b/src/ClaudeDo.Data/Models/WorktreeEntity.cs @@ -18,4 +18,7 @@ public sealed class WorktreeEntity 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!; } diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs index ff0a639..456c933 100644 --- a/src/ClaudeDo.Data/Repositories/ListRepository.cs +++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs @@ -1,157 +1,89 @@ using ClaudeDo.Data.Models; -using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Data.Repositories; public sealed class ListRepository { - private readonly SqliteConnectionFactory _factory; + private readonly ClaudeDoDbContext _context; - public ListRepository(SqliteConnectionFactory factory) => _factory = factory; + public ListRepository(ClaudeDoDbContext context) => _context = context; public async Task AddAsync(ListEntity entity, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO lists (id, name, created_at, working_dir, default_commit_type) - VALUES (@id, @name, @created_at, @working_dir, @default_commit_type) - """; - cmd.Parameters.AddWithValue("@id", entity.Id); - cmd.Parameters.AddWithValue("@name", entity.Name); - cmd.Parameters.AddWithValue("@created_at", entity.CreatedAt.ToString("o")); - cmd.Parameters.AddWithValue("@working_dir", (object?)entity.WorkingDir ?? DBNull.Value); - cmd.Parameters.AddWithValue("@default_commit_type", entity.DefaultCommitType); - await cmd.ExecuteNonQueryAsync(ct); + _context.Lists.Add(entity); + await _context.SaveChangesAsync(ct); } public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - UPDATE lists SET name = @name, working_dir = @working_dir, - default_commit_type = @default_commit_type - WHERE id = @id - """; - cmd.Parameters.AddWithValue("@id", entity.Id); - cmd.Parameters.AddWithValue("@name", entity.Name); - cmd.Parameters.AddWithValue("@working_dir", (object?)entity.WorkingDir ?? DBNull.Value); - cmd.Parameters.AddWithValue("@default_commit_type", entity.DefaultCommitType); - await cmd.ExecuteNonQueryAsync(ct); + _context.Lists.Update(entity); + await _context.SaveChangesAsync(ct); } public async Task DeleteAsync(string listId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "DELETE FROM lists WHERE id = @id"; - cmd.Parameters.AddWithValue("@id", listId); - await cmd.ExecuteNonQueryAsync(ct); + await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct); } public async Task GetByIdAsync(string listId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT id, name, created_at, working_dir, default_commit_type FROM lists WHERE id = @id"; - cmd.Parameters.AddWithValue("@id", listId); - - await using var reader = await cmd.ExecuteReaderAsync(ct); - if (!await reader.ReadAsync(ct)) return null; - return ReadList(reader); + return await _context.Lists.FirstOrDefaultAsync(l => l.Id == listId, ct); } public async Task> GetAllAsync(CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT id, name, created_at, working_dir, default_commit_type FROM lists ORDER BY created_at"; - - await using var reader = await cmd.ExecuteReaderAsync(ct); - var result = new List(); - while (await reader.ReadAsync(ct)) - result.Add(ReadList(reader)); - return result; + return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct); } public async Task> GetTagsAsync(string listId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - SELECT t.id, t.name FROM tags t - JOIN list_tags lt ON lt.tag_id = t.id - WHERE lt.list_id = @list_id - """; - cmd.Parameters.AddWithValue("@list_id", listId); - - await using var reader = await cmd.ExecuteReaderAsync(ct); - var result = new List(); - while (await reader.ReadAsync(ct)) - result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) }); - return result; + 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) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "INSERT OR IGNORE INTO list_tags (list_id, tag_id) VALUES (@list_id, @tag_id)"; - cmd.Parameters.AddWithValue("@list_id", listId); - cmd.Parameters.AddWithValue("@tag_id", tagId); - await cmd.ExecuteNonQueryAsync(ct); + 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) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "DELETE FROM list_tags WHERE list_id = @list_id AND tag_id = @tag_id"; - cmd.Parameters.AddWithValue("@list_id", listId); - cmd.Parameters.AddWithValue("@tag_id", tagId); - await cmd.ExecuteNonQueryAsync(ct); + 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); + } } public async Task GetConfigAsync(string listId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT list_id, model, system_prompt, agent_path FROM list_config WHERE list_id = @list_id"; - cmd.Parameters.AddWithValue("@list_id", listId); + return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct); + } - await using var reader = await cmd.ExecuteReaderAsync(ct); - if (!await reader.ReadAsync(ct)) return null; - return new ListConfigEntity + public async Task SetConfigAsync(ListConfigEntity config, CancellationToken ct = default) + { + var existing = await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == config.ListId, ct); + if (existing is null) { - ListId = reader.GetString(0), - Model = reader.IsDBNull(1) ? null : reader.GetString(1), - SystemPrompt = reader.IsDBNull(2) ? null : reader.GetString(2), - AgentPath = reader.IsDBNull(3) ? null : reader.GetString(3), - }; + _context.ListConfigs.Add(config); + } + else + { + existing.Model = config.Model; + existing.SystemPrompt = config.SystemPrompt; + existing.AgentPath = config.AgentPath; + } + await _context.SaveChangesAsync(ct); } - - public async Task SetConfigAsync(ListConfigEntity entity, CancellationToken ct = default) - { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT OR REPLACE INTO list_config (list_id, model, system_prompt, agent_path) - VALUES (@list_id, @model, @system_prompt, @agent_path) - """; - cmd.Parameters.AddWithValue("@list_id", entity.ListId); - cmd.Parameters.AddWithValue("@model", (object?)entity.Model ?? DBNull.Value); - cmd.Parameters.AddWithValue("@system_prompt", (object?)entity.SystemPrompt ?? DBNull.Value); - cmd.Parameters.AddWithValue("@agent_path", (object?)entity.AgentPath ?? DBNull.Value); - await cmd.ExecuteNonQueryAsync(ct); - } - - private static ListEntity ReadList(SqliteDataReader reader) => new() - { - Id = reader.GetString(0), - Name = reader.GetString(1), - CreatedAt = DateTime.Parse(reader.GetString(2)), - WorkingDir = reader.IsDBNull(3) ? null : reader.GetString(3), - DefaultCommitType = reader.GetString(4), - }; } diff --git a/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs b/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs index 68a77b9..5369701 100644 --- a/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs @@ -1,81 +1,41 @@ using ClaudeDo.Data.Models; -using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Data.Repositories; public sealed class SubtaskRepository { - private readonly SqliteConnectionFactory _factory; + private readonly ClaudeDoDbContext _context; - public SubtaskRepository(SqliteConnectionFactory factory) => _factory = factory; - - public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default) - { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT id, task_id, title, completed, order_num, created_at FROM subtasks WHERE task_id = @task_id ORDER BY order_num"; - cmd.Parameters.AddWithValue("@task_id", taskId); - - await using var reader = await cmd.ExecuteReaderAsync(ct); - var result = new List(); - while (await reader.ReadAsync(ct)) - result.Add(ReadSubtask(reader)); - return result; - } + public SubtaskRepository(ClaudeDoDbContext context) => _context = context; public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO subtasks (id, task_id, title, completed, order_num, created_at) - VALUES (@id, @task_id, @title, @completed, @order_num, @created_at) - """; - BindSubtask(cmd, entity); - await cmd.ExecuteNonQueryAsync(ct); + _context.Subtasks.Add(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default) + { + return await _context.Subtasks + .Where(s => s.TaskId == taskId) + .OrderBy(s => s.OrderNum) + .ToListAsync(ct); } public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - UPDATE subtasks SET title = @title, completed = @completed, order_num = @order_num - WHERE id = @id - """; - cmd.Parameters.AddWithValue("@id", entity.Id); - cmd.Parameters.AddWithValue("@title", entity.Title); - cmd.Parameters.AddWithValue("@completed", entity.Completed ? 1 : 0); - cmd.Parameters.AddWithValue("@order_num", entity.OrderNum); - await cmd.ExecuteNonQueryAsync(ct); + _context.Subtasks.Update(entity); + await _context.SaveChangesAsync(ct); } - public async Task DeleteAsync(string id, CancellationToken ct = default) + public async Task DeleteAsync(string subtaskId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "DELETE FROM subtasks WHERE id = @id"; - cmd.Parameters.AddWithValue("@id", id); - await cmd.ExecuteNonQueryAsync(ct); + await _context.Subtasks.Where(s => s.Id == subtaskId).ExecuteDeleteAsync(ct); } - private static void BindSubtask(SqliteCommand cmd, SubtaskEntity e) + public async Task DeleteByTaskIdAsync(string taskId, CancellationToken ct = default) { - cmd.Parameters.AddWithValue("@id", e.Id); - cmd.Parameters.AddWithValue("@task_id", e.TaskId); - cmd.Parameters.AddWithValue("@title", e.Title); - cmd.Parameters.AddWithValue("@completed", e.Completed ? 1 : 0); - cmd.Parameters.AddWithValue("@order_num", e.OrderNum); - cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o")); + await _context.Subtasks.Where(s => s.TaskId == taskId).ExecuteDeleteAsync(ct); } - - private static SubtaskEntity ReadSubtask(SqliteDataReader r) => new() - { - Id = r.GetString(0), - TaskId = r.GetString(1), - Title = r.GetString(2), - Completed = r.GetInt64(3) != 0, - OrderNum = r.GetInt32(4), - CreatedAt = DateTime.Parse(r.GetString(5)), - }; } diff --git a/src/ClaudeDo.Data/Repositories/TagRepository.cs b/src/ClaudeDo.Data/Repositories/TagRepository.cs index d15fa0e..647a927 100644 --- a/src/ClaudeDo.Data/Repositories/TagRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TagRepository.cs @@ -1,47 +1,28 @@ using ClaudeDo.Data.Models; -using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Data.Repositories; public sealed class TagRepository { - private readonly SqliteConnectionFactory _factory; + private readonly ClaudeDoDbContext _context; - public TagRepository(SqliteConnectionFactory factory) => _factory = factory; + public TagRepository(ClaudeDoDbContext context) => _context = context; public async Task> GetAllAsync(CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT id, name FROM tags ORDER BY id"; - - await using var reader = await cmd.ExecuteReaderAsync(ct); - var result = new List(); - while (await reader.ReadAsync(ct)) - result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) }); - return result; + return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct); } public async Task GetOrCreateAsync(string name, CancellationToken ct = default) { - await using var conn = _factory.Open(); - return await GetOrCreateAsync(conn, name, ct); - } - - public static async Task GetOrCreateAsync(SqliteConnection conn, string name, CancellationToken ct = default) - { - await using var sel = conn.CreateCommand(); - sel.CommandText = "SELECT id FROM tags WHERE name = @name"; - sel.Parameters.AddWithValue("@name", name); - - var existing = await sel.ExecuteScalarAsync(ct); + var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct); if (existing is not null) - return (long)existing; + return existing.Id; - await using var ins = conn.CreateCommand(); - ins.CommandText = "INSERT INTO tags (name) VALUES (@name) RETURNING id"; - ins.Parameters.AddWithValue("@name", name); - - return (long)(await ins.ExecuteScalarAsync(ct))!; + var tag = new TagEntity { Name = name }; + _context.Tags.Add(tag); + await _context.SaveChangesAsync(ct); + return tag.Id; } } diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 64de4d2..7fddc90 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -1,171 +1,146 @@ using ClaudeDo.Data.Models; -using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Data.Repositories; public sealed class TaskRepository { - private readonly SqliteConnectionFactory _factory; + private readonly ClaudeDoDbContext _context; - public TaskRepository(SqliteConnectionFactory factory) => _factory = factory; - - #region Status mapping - - private static string ToDb(TaskStatus s) => s switch - { - TaskStatus.Manual => "manual", - TaskStatus.Queued => "queued", - TaskStatus.Running => "running", - TaskStatus.Done => "done", - TaskStatus.Failed => "failed", - _ => throw new ArgumentOutOfRangeException(nameof(s)), - }; - - private static TaskStatus FromDb(string s) => s switch - { - "manual" => TaskStatus.Manual, - "queued" => TaskStatus.Queued, - "running" => TaskStatus.Running, - "done" => TaskStatus.Done, - "failed" => TaskStatus.Failed, - _ => throw new ArgumentOutOfRangeException(nameof(s)), - }; - - #endregion + public TaskRepository(ClaudeDoDbContext context) => _context = context; #region CRUD public async Task AddAsync(TaskEntity entity, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO tasks (id, list_id, title, description, status, scheduled_for, - result, log_path, created_at, started_at, finished_at, commit_type, - model, system_prompt, agent_path) - VALUES (@id, @list_id, @title, @description, @status, @scheduled_for, - @result, @log_path, @created_at, @started_at, @finished_at, @commit_type, - @model, @system_prompt, @agent_path) - """; - BindTask(cmd, entity); - await cmd.ExecuteNonQueryAsync(ct); + _context.Tasks.Add(entity); + await _context.SaveChangesAsync(ct); } public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - UPDATE tasks SET list_id = @list_id, title = @title, description = @description, - status = @status, scheduled_for = @scheduled_for, result = @result, - log_path = @log_path, started_at = @started_at, - finished_at = @finished_at, commit_type = @commit_type, - model = @model, system_prompt = @system_prompt, agent_path = @agent_path - WHERE id = @id - """; - BindTask(cmd, entity); - await cmd.ExecuteNonQueryAsync(ct); + _context.Tasks.Update(entity); + await _context.SaveChangesAsync(ct); } public async Task DeleteAsync(string taskId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "DELETE FROM tasks WHERE id = @id"; - cmd.Parameters.AddWithValue("@id", taskId); - await cmd.ExecuteNonQueryAsync(ct); + await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct); } public async Task GetByIdAsync(string taskId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE id = @id"; - cmd.Parameters.AddWithValue("@id", taskId); - - await using var reader = await cmd.ExecuteReaderAsync(ct); - if (!await reader.ReadAsync(ct)) return null; - return ReadTask(reader); + return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct); } - public async Task> GetByListAsync(string listId, CancellationToken ct = default) + public async Task> GetByListIdAsync(string listId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE list_id = @list_id ORDER BY created_at"; - cmd.Parameters.AddWithValue("@list_id", listId); + return await _context.Tasks + .Where(t => t.ListId == listId) + .OrderBy(t => t.CreatedAt) + .ToListAsync(ct); + } - await using var reader = await cmd.ExecuteReaderAsync(ct); - var result = new List(); - while (await reader.ReadAsync(ct)) - result.Add(ReadTask(reader)); - return result; + // Kept for backwards-compatibility with callers using the old name. + public Task> GetByListAsync(string listId, CancellationToken ct = default) + => GetByListIdAsync(listId, 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(string reason, CancellationToken ct = default) + { + var resultText = "[stale] " + reason; + var now = DateTime.UtcNow; + return await _context.Tasks + .Where(t => t.Status == TaskStatus.Running) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Failed) + .SetProperty(t => t.FinishedAt, now) + .SetProperty(t => t.Result, resultText), ct); } #endregion - #region Tag junction - - public async Task> GetTagsAsync(string taskId, CancellationToken ct = default) - { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - SELECT t.id, t.name FROM tags t - JOIN task_tags tt ON tt.tag_id = t.id - WHERE tt.task_id = @task_id - """; - cmd.Parameters.AddWithValue("@task_id", taskId); - - await using var reader = await cmd.ExecuteReaderAsync(ct); - var result = new List(); - while (await reader.ReadAsync(ct)) - result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) }); - return result; - } + #region Tags public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (@task_id, @tag_id)"; - cmd.Parameters.AddWithValue("@task_id", taskId); - cmd.Parameters.AddWithValue("@tag_id", tagId); - await cmd.ExecuteNonQueryAsync(ct); + 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) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "DELETE FROM task_tags WHERE task_id = @task_id AND tag_id = @tag_id"; - cmd.Parameters.AddWithValue("@task_id", taskId); - cmd.Parameters.AddWithValue("@tag_id", tagId); - await cmd.ExecuteNonQueryAsync(ct); + var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct); + var tag = task.Tags.FirstOrDefault(t => t.Id == tagId); + if (tag is not null) + { + task.Tags.Remove(tag); + await _context.SaveChangesAsync(ct); + } + } + + public async Task> GetTagsAsync(string taskId, CancellationToken ct = default) + { + return await _context.Tasks + .Where(t => t.Id == taskId) + .SelectMany(t => t.Tags) + .ToListAsync(ct); } public async Task> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - SELECT DISTINCT t.id, t.name FROM tags t - WHERE t.id IN ( - SELECT tag_id FROM task_tags WHERE task_id = @task_id - UNION - SELECT lt.tag_id FROM list_tags lt - JOIN tasks tk ON tk.list_id = lt.list_id - WHERE tk.id = @task_id - ) - """; - cmd.Parameters.AddWithValue("@task_id", taskId); - - await using var reader = await cmd.ExecuteReaderAsync(ct); - var result = new List(); - while (await reader.ReadAsync(ct)) - result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) }); - return result; + 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 @@ -174,146 +149,38 @@ public sealed class TaskRepository public async Task GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default) { - // Atomically claim the next queued agent task: the UPDATE flips its - // status to 'running' in the same statement that returns its row, - // eliminating the TOCTOU gap where two queue-loop iterations could - // both select the same queued task before either marked it running. - // The caller is responsible for populating started_at shortly after. - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - UPDATE tasks - SET status = 'running' + // Atomic queue claim: UPDATE + RETURNING in one statement prevents TOCTOU races. + // Uses raw SQL because EF cannot express UPDATE...RETURNING. + // Includes both task-level and list-level "agent" tag so lists tagged "agent" + // automatically enqueue all their tasks without per-task tagging. + // EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — use the same format for comparison. + var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff"); + var result = await _context.Tasks.FromSqlRaw(""" + UPDATE tasks SET status = 'running' WHERE id = ( - SELECT t.id - FROM tasks t + SELECT t.id FROM tasks t WHERE t.status = 'queued' - AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now) - AND EXISTS ( - SELECT 1 FROM task_tags tt - JOIN tags tg ON tg.id = tt.tag_id - WHERE tt.task_id = t.id AND tg.name = 'agent' - UNION - SELECT 1 FROM list_tags lt - JOIN tags tg ON tg.id = lt.tag_id - WHERE lt.list_id = t.list_id AND tg.name = 'agent' + AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0}) + AND ( + EXISTS ( + SELECT 1 FROM task_tags tt + JOIN tags tg ON tg.id = tt.tag_id + WHERE tt.task_id = t.id AND tg.name = 'agent' + ) + OR EXISTS ( + SELECT 1 FROM list_tags lt + JOIN tags tg ON tg.id = lt.tag_id + WHERE lt.list_id = t.list_id AND tg.name = 'agent' + ) ) ORDER BY t.created_at ASC LIMIT 1 ) - RETURNING id, list_id, title, description, status, scheduled_for, - result, log_path, created_at, started_at, finished_at, commit_type, - model, system_prompt, agent_path - """; - cmd.Parameters.AddWithValue("@now", now.ToString("o")); + RETURNING * + """, nowStr).ToListAsync(ct); - await using var reader = await cmd.ExecuteReaderAsync(ct); - if (!await reader.ReadAsync(ct)) return null; - return ReadTask(reader); + return result.FirstOrDefault(); } #endregion - - #region Transitions - - public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default) - { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE tasks SET log_path = @log_path WHERE id = @id"; - cmd.Parameters.AddWithValue("@id", taskId); - cmd.Parameters.AddWithValue("@log_path", logPath); - await cmd.ExecuteNonQueryAsync(ct); - } - - public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default) - { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE tasks SET status = 'running', started_at = @started_at WHERE id = @id"; - cmd.Parameters.AddWithValue("@id", taskId); - cmd.Parameters.AddWithValue("@started_at", startedAt.ToString("o")); - await cmd.ExecuteNonQueryAsync(ct); - } - - public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default) - { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE tasks SET status = 'done', finished_at = @finished_at, result = @result WHERE id = @id"; - cmd.Parameters.AddWithValue("@id", taskId); - cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o")); - cmd.Parameters.AddWithValue("@result", (object?)result ?? DBNull.Value); - await cmd.ExecuteNonQueryAsync(ct); - } - - public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? errorMarkdown, CancellationToken ct = default) - { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE tasks SET status = 'failed', finished_at = @finished_at, result = @result WHERE id = @id"; - cmd.Parameters.AddWithValue("@id", taskId); - cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o")); - cmd.Parameters.AddWithValue("@result", (object?)errorMarkdown ?? DBNull.Value); - await cmd.ExecuteNonQueryAsync(ct); - } - - public async Task FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default) - { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - UPDATE tasks SET status = 'failed', - finished_at = @now, - result = '[stale] ' || @reason - WHERE status = 'running' - """; - cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o")); - cmd.Parameters.AddWithValue("@reason", reason); - return await cmd.ExecuteNonQueryAsync(ct); - } - - #endregion - - #region Helpers - - private static void BindTask(SqliteCommand cmd, TaskEntity e) - { - cmd.Parameters.AddWithValue("@id", e.Id); - cmd.Parameters.AddWithValue("@list_id", e.ListId); - cmd.Parameters.AddWithValue("@title", e.Title); - cmd.Parameters.AddWithValue("@description", (object?)e.Description ?? DBNull.Value); - cmd.Parameters.AddWithValue("@status", ToDb(e.Status)); - cmd.Parameters.AddWithValue("@scheduled_for", e.ScheduledFor.HasValue ? e.ScheduledFor.Value.ToString("o") : DBNull.Value); - cmd.Parameters.AddWithValue("@result", (object?)e.Result ?? DBNull.Value); - cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value); - cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o")); - cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value); - cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value); - cmd.Parameters.AddWithValue("@commit_type", e.CommitType); - cmd.Parameters.AddWithValue("@model", (object?)e.Model ?? DBNull.Value); - cmd.Parameters.AddWithValue("@system_prompt", (object?)e.SystemPrompt ?? DBNull.Value); - cmd.Parameters.AddWithValue("@agent_path", (object?)e.AgentPath ?? DBNull.Value); - } - - private static TaskEntity ReadTask(SqliteDataReader r) => new() - { - Id = r.GetString(0), - ListId = r.GetString(1), - Title = r.GetString(2), - Description = r.IsDBNull(3) ? null : r.GetString(3), - Status = FromDb(r.GetString(4)), - ScheduledFor = r.IsDBNull(5) ? null : DateTime.Parse(r.GetString(5)), - Result = r.IsDBNull(6) ? null : r.GetString(6), - LogPath = r.IsDBNull(7) ? null : r.GetString(7), - CreatedAt = DateTime.Parse(r.GetString(8)), - StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)), - FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)), - CommitType = r.GetString(11), - Model = r.IsDBNull(12) ? null : r.GetString(12), - SystemPrompt = r.IsDBNull(13) ? null : r.GetString(13), - AgentPath = r.IsDBNull(14) ? null : r.GetString(14), - }; - - #endregion } diff --git a/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs index a635113..0830523 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs @@ -1,139 +1,44 @@ -using System.Globalization; using ClaudeDo.Data.Models; -using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Data.Repositories; public sealed class TaskRunRepository { - private readonly SqliteConnectionFactory _factory; + private readonly ClaudeDoDbContext _context; - public TaskRunRepository(SqliteConnectionFactory factory) => _factory = factory; + public TaskRunRepository(ClaudeDoDbContext context) => _context = context; public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO task_runs (id, task_id, run_number, session_id, is_retry, prompt, - result_markdown, structured_output, error_markdown, exit_code, - turn_count, tokens_in, tokens_out, log_path, started_at, finished_at) - VALUES (@id, @task_id, @run_number, @session_id, @is_retry, @prompt, - @result_markdown, @structured_output, @error_markdown, @exit_code, - @turn_count, @tokens_in, @tokens_out, @log_path, @started_at, @finished_at) - """; - BindRun(cmd, entity); - await cmd.ExecuteNonQueryAsync(ct); + _context.TaskRuns.Add(entity); + await _context.SaveChangesAsync(ct); } public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - UPDATE task_runs SET session_id = @session_id, - result_markdown = @result_markdown, - structured_output = @structured_output, - error_markdown = @error_markdown, - exit_code = @exit_code, - turn_count = @turn_count, - tokens_in = @tokens_in, - tokens_out = @tokens_out, - finished_at = @finished_at - WHERE id = @id - """; - cmd.Parameters.AddWithValue("@id", entity.Id); - cmd.Parameters.AddWithValue("@session_id", (object?)entity.SessionId ?? DBNull.Value); - cmd.Parameters.AddWithValue("@result_markdown", (object?)entity.ResultMarkdown ?? DBNull.Value); - cmd.Parameters.AddWithValue("@structured_output", (object?)entity.StructuredOutputJson ?? DBNull.Value); - cmd.Parameters.AddWithValue("@error_markdown", (object?)entity.ErrorMarkdown ?? DBNull.Value); - cmd.Parameters.AddWithValue("@exit_code", entity.ExitCode.HasValue ? entity.ExitCode.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@turn_count", entity.TurnCount.HasValue ? entity.TurnCount.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@tokens_in", entity.TokensIn.HasValue ? entity.TokensIn.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@tokens_out", entity.TokensOut.HasValue ? entity.TokensOut.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@finished_at", entity.FinishedAt.HasValue ? entity.FinishedAt.Value.ToString("o") : DBNull.Value); - await cmd.ExecuteNonQueryAsync(ct); + _context.TaskRuns.Update(entity); + await _context.SaveChangesAsync(ct); } - public async Task GetByIdAsync(string runId, CancellationToken ct = default) + public async Task GetByIdAsync(string id, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE id = @id"; - cmd.Parameters.AddWithValue("@id", runId); - - await using var reader = await cmd.ExecuteReaderAsync(ct); - if (!await reader.ReadAsync(ct)) return null; - return ReadRun(reader); + return await _context.TaskRuns.FirstOrDefaultAsync(r => r.Id == id, ct); } public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number"; - cmd.Parameters.AddWithValue("@task_id", taskId); - - await using var reader = await cmd.ExecuteReaderAsync(ct); - var result = new List(); - while (await reader.ReadAsync(ct)) - result.Add(ReadRun(reader)); - return result; + return await _context.TaskRuns + .Where(r => r.TaskId == taskId) + .OrderBy(r => r.RunNumber) + .ToListAsync(ct); } public async Task GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number DESC LIMIT 1"; - cmd.Parameters.AddWithValue("@task_id", taskId); - - await using var reader = await cmd.ExecuteReaderAsync(ct); - if (!await reader.ReadAsync(ct)) return null; - return ReadRun(reader); + return await _context.TaskRuns + .Where(r => r.TaskId == taskId) + .OrderByDescending(r => r.RunNumber) + .FirstOrDefaultAsync(ct); } - - #region Helpers - - private static void BindRun(SqliteCommand cmd, TaskRunEntity e) - { - cmd.Parameters.AddWithValue("@id", e.Id); - cmd.Parameters.AddWithValue("@task_id", e.TaskId); - cmd.Parameters.AddWithValue("@run_number", e.RunNumber); - cmd.Parameters.AddWithValue("@session_id", (object?)e.SessionId ?? DBNull.Value); - cmd.Parameters.AddWithValue("@is_retry", e.IsRetry ? 1 : 0); - cmd.Parameters.AddWithValue("@prompt", e.Prompt); - cmd.Parameters.AddWithValue("@result_markdown", (object?)e.ResultMarkdown ?? DBNull.Value); - cmd.Parameters.AddWithValue("@structured_output", (object?)e.StructuredOutputJson ?? DBNull.Value); - cmd.Parameters.AddWithValue("@error_markdown", (object?)e.ErrorMarkdown ?? DBNull.Value); - cmd.Parameters.AddWithValue("@exit_code", e.ExitCode.HasValue ? e.ExitCode.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@turn_count", e.TurnCount.HasValue ? e.TurnCount.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@tokens_in", e.TokensIn.HasValue ? e.TokensIn.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@tokens_out", e.TokensOut.HasValue ? e.TokensOut.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value); - cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value); - cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value); - } - - private static TaskRunEntity ReadRun(SqliteDataReader r) => new() - { - Id = r.GetString(0), - TaskId = r.GetString(1), - RunNumber = r.GetInt32(2), - SessionId = r.IsDBNull(3) ? null : r.GetString(3), - IsRetry = r.GetInt32(4) != 0, - Prompt = r.GetString(5), - ResultMarkdown = r.IsDBNull(6) ? null : r.GetString(6), - StructuredOutputJson = r.IsDBNull(7) ? null : r.GetString(7), - ErrorMarkdown = r.IsDBNull(8) ? null : r.GetString(8), - ExitCode = r.IsDBNull(9) ? null : r.GetInt32(9), - TurnCount = r.IsDBNull(10) ? null : r.GetInt32(10), - TokensIn = r.IsDBNull(11) ? null : r.GetInt32(11), - TokensOut = r.IsDBNull(12) ? null : r.GetInt32(12), - LogPath = r.IsDBNull(13) ? null : r.GetString(13), - StartedAt = r.IsDBNull(14) ? null : DateTime.Parse(r.GetString(14), null, DateTimeStyles.RoundtripKind), - FinishedAt = r.IsDBNull(15) ? null : DateTime.Parse(r.GetString(15), null, DateTimeStyles.RoundtripKind), - }; - - #endregion } diff --git a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs index ae96a54..39bf109 100644 --- a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs +++ b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs @@ -1,102 +1,43 @@ using ClaudeDo.Data.Models; -using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Data.Repositories; public sealed class WorktreeRepository { - private readonly SqliteConnectionFactory _factory; + private readonly ClaudeDoDbContext _context; - public WorktreeRepository(SqliteConnectionFactory factory) => _factory = factory; - - private static string ToDb(WorktreeState s) => s switch - { - WorktreeState.Active => "active", - WorktreeState.Merged => "merged", - WorktreeState.Discarded => "discarded", - WorktreeState.Kept => "kept", - _ => throw new ArgumentOutOfRangeException(nameof(s)), - }; - - private static WorktreeState FromDb(string s) => s switch - { - "active" => WorktreeState.Active, - "merged" => WorktreeState.Merged, - "discarded" => WorktreeState.Discarded, - "kept" => WorktreeState.Kept, - _ => throw new ArgumentOutOfRangeException(nameof(s)), - }; + public WorktreeRepository(ClaudeDoDbContext context) => _context = context; public async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO worktrees (task_id, path, branch_name, base_commit, head_commit, diff_stat, state, created_at) - VALUES (@task_id, @path, @branch_name, @base_commit, @head_commit, @diff_stat, @state, @created_at) - """; - cmd.Parameters.AddWithValue("@task_id", entity.TaskId); - cmd.Parameters.AddWithValue("@path", entity.Path); - cmd.Parameters.AddWithValue("@branch_name", entity.BranchName); - cmd.Parameters.AddWithValue("@base_commit", entity.BaseCommit); - cmd.Parameters.AddWithValue("@head_commit", (object?)entity.HeadCommit ?? DBNull.Value); - cmd.Parameters.AddWithValue("@diff_stat", (object?)entity.DiffStat ?? DBNull.Value); - cmd.Parameters.AddWithValue("@state", ToDb(entity.State)); - cmd.Parameters.AddWithValue("@created_at", entity.CreatedAt.ToString("o")); - await cmd.ExecuteNonQueryAsync(ct); + _context.Worktrees.Add(entity); + await _context.SaveChangesAsync(ct); } public async Task GetByTaskIdAsync(string taskId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT task_id, path, branch_name, base_commit, head_commit, diff_stat, state, created_at FROM worktrees WHERE task_id = @task_id"; - cmd.Parameters.AddWithValue("@task_id", taskId); - - await using var reader = await cmd.ExecuteReaderAsync(ct); - if (!await reader.ReadAsync(ct)) return null; - return ReadWorktree(reader); + return await _context.Worktrees.FirstOrDefaultAsync(w => w.TaskId == taskId, ct); } public async Task UpdateHeadAsync(string taskId, string headCommit, string? diffStat, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE worktrees SET head_commit = @head_commit, diff_stat = @diff_stat WHERE task_id = @task_id"; - cmd.Parameters.AddWithValue("@task_id", taskId); - cmd.Parameters.AddWithValue("@head_commit", headCommit); - cmd.Parameters.AddWithValue("@diff_stat", (object?)diffStat ?? DBNull.Value); - await cmd.ExecuteNonQueryAsync(ct); + 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 using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "UPDATE worktrees SET state = @state WHERE task_id = @task_id"; - cmd.Parameters.AddWithValue("@task_id", taskId); - cmd.Parameters.AddWithValue("@state", ToDb(state)); - await cmd.ExecuteNonQueryAsync(ct); + 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 using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "DELETE FROM worktrees WHERE task_id = @task_id"; - cmd.Parameters.AddWithValue("@task_id", taskId); - await cmd.ExecuteNonQueryAsync(ct); + await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct); } - - private static WorktreeEntity ReadWorktree(SqliteDataReader r) => new() - { - TaskId = r.GetString(0), - Path = r.GetString(1), - BranchName = r.GetString(2), - BaseCommit = r.GetString(3), - HeadCommit = r.IsDBNull(4) ? null : r.GetString(4), - DiffStat = r.IsDBNull(5) ? null : r.GetString(5), - State = FromDb(r.GetString(6)), - CreatedAt = DateTime.Parse(r.GetString(7)), - }; } diff --git a/src/ClaudeDo.Data/SchemaInitializer.cs b/src/ClaudeDo.Data/SchemaInitializer.cs deleted file mode 100644 index 65e4806..0000000 --- a/src/ClaudeDo.Data/SchemaInitializer.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Reflection; -using Microsoft.Data.Sqlite; - -namespace ClaudeDo.Data; - -/// -/// Applies the embedded schema.sql script. Safe to call on every start — the script uses -/// IF NOT EXISTS / INSERT OR IGNORE. -/// -public static class SchemaInitializer -{ - private const string ResourceName = "ClaudeDo.Data.schema.sql"; - - public static void Apply(SqliteConnectionFactory factory) - { - using var conn = factory.Open(); - ApplyTo(conn); - } - - public static void ApplyTo(SqliteConnection conn) - { - var sql = LoadScript(); - using var tx = conn.BeginTransaction(); - using var cmd = conn.CreateCommand(); - cmd.Transaction = tx; - cmd.CommandText = sql; - cmd.ExecuteNonQuery(); - tx.Commit(); - - ApplyMigrations(conn); - } - - private static void ApplyMigrations(SqliteConnection conn) - { - string[] alterStatements = - [ - "ALTER TABLE tasks ADD COLUMN model TEXT NULL", - "ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL", - "ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL", - ]; - - foreach (var sql in alterStatements) - { - try - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = sql; - cmd.ExecuteNonQuery(); - } - catch (SqliteException ex) when (ex.SqliteErrorCode == 1) - { - // Column already exists — safe to ignore. - } - } - } - - private static string LoadScript() - { - var asm = typeof(SchemaInitializer).Assembly; - using var stream = asm.GetManifestResourceStream(ResourceName) - ?? throw new InvalidOperationException( - $"Embedded resource '{ResourceName}' not found in {asm.GetName().Name}. " + - $"Available: {string.Join(", ", asm.GetManifestResourceNames())}"); - using var reader = new StreamReader(stream); - return reader.ReadToEnd(); - } -} diff --git a/src/ClaudeDo.Data/SqliteConnectionFactory.cs b/src/ClaudeDo.Data/SqliteConnectionFactory.cs deleted file mode 100644 index 43da6fa..0000000 --- a/src/ClaudeDo.Data/SqliteConnectionFactory.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Microsoft.Data.Sqlite; - -namespace ClaudeDo.Data; - -/// -/// Opens instances pointed at . -/// First call ensures the parent directory exists, enables WAL and foreign keys. -/// -public sealed class SqliteConnectionFactory -{ - public string DbPath { get; } - private readonly string _connectionString; - private int _walApplied; - - public SqliteConnectionFactory(string dbPath) - { - DbPath = Paths.Expand(dbPath); - Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!); - - _connectionString = new SqliteConnectionStringBuilder - { - DataSource = DbPath, - Mode = SqliteOpenMode.ReadWriteCreate, - Cache = SqliteCacheMode.Shared, - }.ToString(); - } - - public SqliteConnection Open() - { - var conn = new SqliteConnection(_connectionString); - conn.Open(); - - // WAL is a persistent DB-level setting; applying it once per process is enough, - // but idempotent so we do it defensively on the first connection we hand out. - if (Interlocked.Exchange(ref _walApplied, 1) == 0) - { - using var pragma = conn.CreateCommand(); - pragma.CommandText = "PRAGMA journal_mode=WAL;"; - pragma.ExecuteNonQuery(); - } - - using var fk = conn.CreateCommand(); - fk.CommandText = "PRAGMA foreign_keys=ON;"; - fk.ExecuteNonQuery(); - - return conn; - } -} diff --git a/src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs b/src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs index dc4814c..007b36c 100644 --- a/src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs +++ b/src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs @@ -1,5 +1,6 @@ using ClaudeDo.Data; using ClaudeDo.Installer.Core; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Installer.Steps; @@ -14,8 +15,11 @@ public sealed class InitDatabaseStep : IInstallStep var expandedPath = Paths.Expand(ctx.DbPath); progress.Report($"Initializing database at {expandedPath}"); - var factory = new SqliteConnectionFactory(expandedPath); - SchemaInitializer.Apply(factory); + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={expandedPath}") + .Options; + using var context = new ClaudeDoDbContext(options); + ClaudeDoDbContext.MigrateAndConfigure(context); progress.Report("Schema applied successfully"); return Task.FromResult(StepResult.Ok()); diff --git a/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs index 4309d43..4d2a7e8 100644 --- a/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs @@ -2,18 +2,20 @@ using System.Collections.ObjectModel; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Views; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Ui.ViewModels; public partial class MainWindowViewModel : ViewModelBase { - private readonly ListRepository _listRepo; + private readonly IDbContextFactory _dbFactory; private readonly WorkerClient _worker; private readonly Func _listEditorFactory; @@ -26,14 +28,14 @@ public partial class MainWindowViewModel : ViewModelBase public StatusBarViewModel StatusBar { get; } public MainWindowViewModel( - ListRepository listRepo, + IDbContextFactory dbFactory, WorkerClient worker, TaskListViewModel taskList, TaskDetailViewModel taskDetail, StatusBarViewModel statusBar, Func listEditorFactory) { - _listRepo = listRepo; + _dbFactory = dbFactory; _worker = worker; _listEditorFactory = listEditorFactory; TaskList = taskList; @@ -48,7 +50,9 @@ public partial class MainWindowViewModel : ViewModelBase { try { - var lists = await _listRepo.GetAllAsync(); + using var context = _dbFactory.CreateDbContext(); + var listRepo = new ListRepository(context); + var lists = await listRepo.GetAllAsync(); foreach (var l in lists) Lists.Add(new ListItemViewModel(l)); } @@ -91,10 +95,12 @@ public partial class MainWindowViewModel : ViewModelBase try { - await _listRepo.AddAsync(entity); + using var context = _dbFactory.CreateDbContext(); + var listRepo = new ListRepository(context); + await listRepo.AddAsync(entity); var configEntity = editor.BuildConfig(entity.Id); if (configEntity is not null) - await _listRepo.SetConfigAsync(configEntity); + await listRepo.SetConfigAsync(configEntity); Lists.Add(new ListItemViewModel(entity)); } catch (Exception ex) @@ -107,10 +113,17 @@ public partial class MainWindowViewModel : ViewModelBase private async Task EditList() { if (SelectedList is null) return; - var existing = await _listRepo.GetByIdAsync(SelectedList.Id); - if (existing is null) return; - var config = await _listRepo.GetConfigAsync(existing.Id); + ListEntity? existing; + ListConfigEntity? config; + using (var context = _dbFactory.CreateDbContext()) + { + var listRepo = new ListRepository(context); + existing = await listRepo.GetByIdAsync(SelectedList.Id); + if (existing is null) return; + config = await listRepo.GetConfigAsync(existing.Id); + } + var editor = _listEditorFactory(); await editor.LoadAgentsAsync(_worker); editor.InitForEdit(existing, config); @@ -125,10 +138,12 @@ public partial class MainWindowViewModel : ViewModelBase try { - await _listRepo.UpdateAsync(entity); + using var context = _dbFactory.CreateDbContext(); + var listRepo = new ListRepository(context); + await listRepo.UpdateAsync(entity); var configEntity = editor.BuildConfig(entity.Id); if (configEntity is not null) - await _listRepo.SetConfigAsync(configEntity); + await listRepo.SetConfigAsync(configEntity); SelectedList.Name = entity.Name; SelectedList.WorkingDir = entity.WorkingDir; SelectedList.DefaultCommitType = entity.DefaultCommitType; @@ -146,7 +161,9 @@ public partial class MainWindowViewModel : ViewModelBase // TODO: confirmation dialog try { - await _listRepo.DeleteAsync(SelectedList.Id); + using var context = _dbFactory.CreateDbContext(); + var listRepo = new ListRepository(context); + await listRepo.DeleteAsync(SelectedList.Id); Lists.Remove(SelectedList); SelectedList = null; } diff --git a/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs index 3fbbd50..4b4a144 100644 --- a/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics; using System.IO; +using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; @@ -9,18 +10,15 @@ using ClaudeDo.Ui.Helpers; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Ui.ViewModels; public partial class TaskDetailViewModel : ViewModelBase { - private readonly TaskRepository _taskRepo; - private readonly WorktreeRepository _worktreeRepo; - private readonly ListRepository _listRepo; + private readonly IDbContextFactory _dbFactory; private readonly GitService _git; private readonly WorkerClient _worker; - private readonly TagRepository _tagRepo; - private readonly SubtaskRepository _subtaskRepo; [ObservableProperty] private string _title = ""; [ObservableProperty] private string? _description; @@ -62,17 +60,11 @@ public partial class TaskDetailViewModel : ViewModelBase public event Action? TaskChanged; - public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo, - ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo, - SubtaskRepository subtaskRepo) + public TaskDetailViewModel(IDbContextFactory dbFactory, GitService git, WorkerClient worker) { - _taskRepo = taskRepo; - _worktreeRepo = worktreeRepo; - _listRepo = listRepo; + _dbFactory = dbFactory; _git = git; _worker = worker; - _tagRepo = tagRepo; - _subtaskRepo = subtaskRepo; worker.TaskMessageEvent += OnTaskMessage; worker.WorktreeUpdatedEvent += OnWorktreeUpdated; @@ -98,8 +90,24 @@ public partial class TaskDetailViewModel : ViewModelBase try { - var task = await _taskRepo.GetByIdAsync(taskId, ct); - if (task is null) return; + TaskEntity? task; + List tags; + List subtasks; + + using (var context = _dbFactory.CreateDbContext()) + { + var taskRepo = new TaskRepository(context); + task = await taskRepo.GetByIdAsync(taskId, ct); + if (task is null) return; + ct.ThrowIfCancellationRequested(); + + tags = await taskRepo.GetTagsAsync(taskId, ct); + ct.ThrowIfCancellationRequested(); + + var subtaskRepo = new SubtaskRepository(context); + subtasks = await subtaskRepo.GetByTaskIdAsync(taskId, ct); + } + ct.ThrowIfCancellationRequested(); if (AvailableAgents.Count == 0) @@ -149,14 +157,12 @@ public partial class TaskDetailViewModel : ViewModelBase } Tags.Clear(); - var tags = await _taskRepo.GetTagsAsync(taskId, ct); foreach (var tag in tags) Tags.Add(tag); // Tear down old subtask subscriptions before replacing them. foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged; Subtasks.Clear(); - var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId, ct); foreach (var s in subtasks) { var vm = SubtaskItemViewModel.From(s); @@ -181,7 +187,9 @@ public partial class TaskDetailViewModel : ViewModelBase { if (_isLoading || _taskId is null) return; - var entity = await _taskRepo.GetByIdAsync(_taskId); + using var context = _dbFactory.CreateDbContext(); + var taskRepo = new TaskRepository(context); + var entity = await taskRepo.GetByIdAsync(_taskId); if (entity is null) return; entity.Title = Title; @@ -196,7 +204,7 @@ public partial class TaskDetailViewModel : ViewModelBase if (Enum.TryParse(StatusChoice, true, out var status)) entity.Status = status; - await _taskRepo.UpdateAsync(entity); + await taskRepo.UpdateAsync(entity); StatusText = entity.Status.ToString().ToLowerInvariant(); TaskChanged?.Invoke(_taskId); } @@ -207,11 +215,15 @@ public partial class TaskDetailViewModel : ViewModelBase var name = NewTagInput.Trim(); if (string.IsNullOrEmpty(name) || _taskId is null) return; - var tagId = await _tagRepo.GetOrCreateAsync(name); - await _taskRepo.AddTagAsync(_taskId, tagId); + using var context = _dbFactory.CreateDbContext(); + var tagRepo = new TagRepository(context); + var taskRepo = new TaskRepository(context); + + var tagId = await tagRepo.GetOrCreateAsync(name); + await taskRepo.AddTagAsync(_taskId, tagId); Tags.Clear(); - var tags = await _taskRepo.GetTagsAsync(_taskId); + var tags = await taskRepo.GetTagsAsync(_taskId); foreach (var tag in tags) Tags.Add(tag); @@ -223,7 +235,9 @@ public partial class TaskDetailViewModel : ViewModelBase private async Task RemoveTag(TagEntity tag) { if (_taskId is null) return; - await _taskRepo.RemoveTagAsync(_taskId, tag.Id); + using var context = _dbFactory.CreateDbContext(); + var taskRepo = new TaskRepository(context); + await taskRepo.RemoveTagAsync(_taskId, tag.Id); Tags.Remove(tag); TaskChanged?.Invoke(_taskId); } @@ -241,7 +255,9 @@ public partial class TaskDetailViewModel : ViewModelBase OrderNum = Subtasks.Count, CreatedAt = DateTime.UtcNow, }; - await _subtaskRepo.AddAsync(entity); + using var context = _dbFactory.CreateDbContext(); + var subtaskRepo = new SubtaskRepository(context); + await subtaskRepo.AddAsync(entity); var vm = SubtaskItemViewModel.From(entity); vm.PropertyChanged += OnSubtaskPropertyChanged; Subtasks.Add(vm); @@ -251,7 +267,11 @@ public partial class TaskDetailViewModel : ViewModelBase private async Task RemoveSubtask(SubtaskItemViewModel item) { if (!string.IsNullOrEmpty(item.Id)) - await _subtaskRepo.DeleteAsync(item.Id); + { + using var context = _dbFactory.CreateDbContext(); + var subtaskRepo = new SubtaskRepository(context); + await subtaskRepo.DeleteAsync(item.Id); + } item.PropertyChanged -= OnSubtaskPropertyChanged; Subtasks.Remove(item); } @@ -262,7 +282,9 @@ public partial class TaskDetailViewModel : ViewModelBase if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return; try { - await _subtaskRepo.UpdateAsync(new SubtaskEntity + using var context = _dbFactory.CreateDbContext(); + var subtaskRepo = new SubtaskRepository(context); + await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = _taskId ?? "", @@ -321,7 +343,9 @@ public partial class TaskDetailViewModel : ViewModelBase private async Task LoadWorktreeAsync(string taskId) { - var wt = await _worktreeRepo.GetByTaskIdAsync(taskId); + using var context = _dbFactory.CreateDbContext(); + var wtRepo = new WorktreeRepository(context); + var wt = await wtRepo.GetByTaskIdAsync(taskId); HasWorktree = wt is not null; if (wt is not null) { @@ -378,14 +402,27 @@ public partial class TaskDetailViewModel : ViewModelBase private async Task MergeIntoMainAsync() { if (_taskId is null || _listId is null) return; - var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId); - var list = await _listRepo.GetByIdAsync(_listId); + + WorktreeEntity? wt; + ListEntity? list; + using (var context = _dbFactory.CreateDbContext()) + { + var wtRepo = new WorktreeRepository(context); + wt = await wtRepo.GetByTaskIdAsync(_taskId); + var listRepo = new ListRepository(context); + list = await listRepo.GetByIdAsync(_listId); + } if (wt is null || list?.WorkingDir is null) return; await _git.MergeFfOnlyAsync(list.WorkingDir, wt.BranchName); await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true); await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true); - await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Merged); + + using (var context = _dbFactory.CreateDbContext()) + { + var wtRepo = new WorktreeRepository(context); + await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Merged); + } await LoadWorktreeAsync(_taskId); } @@ -393,12 +430,25 @@ public partial class TaskDetailViewModel : ViewModelBase private async Task KeepAsBranchAsync() { if (_taskId is null || _listId is null) return; - var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId); - var list = await _listRepo.GetByIdAsync(_listId); + + WorktreeEntity? wt; + ListEntity? list; + using (var context = _dbFactory.CreateDbContext()) + { + var wtRepo = new WorktreeRepository(context); + wt = await wtRepo.GetByTaskIdAsync(_taskId); + var listRepo = new ListRepository(context); + list = await listRepo.GetByIdAsync(_listId); + } if (wt is null || list?.WorkingDir is null) return; await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true); - await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Kept); + + using (var context = _dbFactory.CreateDbContext()) + { + var wtRepo = new WorktreeRepository(context); + await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Kept); + } await LoadWorktreeAsync(_taskId); } @@ -406,13 +456,26 @@ public partial class TaskDetailViewModel : ViewModelBase private async Task DiscardAsync() { if (_taskId is null || _listId is null) return; - var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId); - var list = await _listRepo.GetByIdAsync(_listId); + + WorktreeEntity? wt; + ListEntity? list; + using (var context = _dbFactory.CreateDbContext()) + { + var wtRepo = new WorktreeRepository(context); + wt = await wtRepo.GetByTaskIdAsync(_taskId); + var listRepo = new ListRepository(context); + list = await listRepo.GetByIdAsync(_listId); + } if (wt is null || list?.WorkingDir is null) return; await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true); await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true); - await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Discarded); + + using (var context = _dbFactory.CreateDbContext()) + { + var wtRepo = new WorktreeRepository(context); + await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Discarded); + } await LoadWorktreeAsync(_taskId); } diff --git a/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs index 9a172b6..d929ceb 100644 --- a/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs @@ -1,17 +1,19 @@ using System.Collections.ObjectModel; using System.IO; +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.ViewModels; public partial class TaskEditorViewModel : ViewModelBase { - private readonly SubtaskRepository _subtaskRepo; + private readonly IDbContextFactory _dbFactory; [ObservableProperty] private string _title = ""; [ObservableProperty] private string? _description; @@ -40,9 +42,9 @@ public partial class TaskEditorViewModel : ViewModelBase public static string[] StatusChoices { get; } = ["manual", "queued"]; - public TaskEditorViewModel(SubtaskRepository subtaskRepo) + public TaskEditorViewModel(IDbContextFactory dbFactory) { - _subtaskRepo = subtaskRepo; + _dbFactory = dbFactory; } public async Task LoadAgentsAsync(WorkerClient worker) @@ -116,7 +118,9 @@ public partial class TaskEditorViewModel : ViewModelBase WindowTitle = $"Edit Task: {entity.Title}"; Subtasks.Clear(); - var list = await _subtaskRepo.GetByTaskIdAsync(entity.Id, ct); + using var context = _dbFactory.CreateDbContext(); + var subtaskRepo = new SubtaskRepository(context); + var list = await subtaskRepo.GetByTaskIdAsync(entity.Id, ct); foreach (var s in list) Subtasks.Add(SubtaskItemViewModel.From(s)); } @@ -196,36 +200,42 @@ public partial class TaskEditorViewModel : ViewModelBase // Persist subtask changes if (_editId is not null) { - var existing = await _subtaskRepo.GetByTaskIdAsync(taskId); + using var context = _dbFactory.CreateDbContext(); + var subtaskRepo = new SubtaskRepository(context); + var existing = await subtaskRepo.GetByTaskIdAsync(taskId); var existingIds = existing.Select(s => s.Id).ToHashSet(); var currentIds = Subtasks.Where(s => s.Id != "").Select(s => s.Id).ToHashSet(); // Deleted foreach (var id in existingIds.Except(currentIds)) - await _subtaskRepo.DeleteAsync(id); + await subtaskRepo.DeleteAsync(id); // Updated foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i))) { if (vm.Id == "") continue; if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted) - await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow }); + await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow }); else { // update order_num if position changed var orig = existing.FirstOrDefault(e => e.Id == vm.Id); if (orig is not null && orig.OrderNum != idx) - await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = orig.CreatedAt }); + await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = orig.CreatedAt }); } } } // Added (id == "" means new) - foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == "")) { - if (string.IsNullOrWhiteSpace(vm.Title)) continue; - var newId = Guid.NewGuid().ToString(); - await _subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow }); + using var context = _dbFactory.CreateDbContext(); + var subtaskRepo = new SubtaskRepository(context); + foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == "")) + { + if (string.IsNullOrWhiteSpace(vm.Title)) continue; + var newId = Guid.NewGuid().ToString(); + await subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow }); + } } _tcs.TrySetResult(entity); diff --git a/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs index 16afd51..1df6795 100644 --- a/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs @@ -2,21 +2,21 @@ using System.Collections.ObjectModel; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Views; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.ViewModels; public partial class TaskListViewModel : ViewModelBase { - private readonly TaskRepository _taskRepo; - private readonly TagRepository _tagRepo; - private readonly ListRepository _listRepo; + private readonly IDbContextFactory _dbFactory; private readonly WorkerClient _worker; private readonly Func _editorFactory; private readonly Action _showMessage; @@ -33,13 +33,10 @@ public partial class TaskListViewModel : ViewModelBase partial void OnSelectedTaskChanged(TaskItemViewModel? value) => SelectedTaskChanged?.Invoke(value); - public TaskListViewModel(TaskRepository taskRepo, TagRepository tagRepo, - ListRepository listRepo, WorkerClient worker, + public TaskListViewModel(IDbContextFactory dbFactory, WorkerClient worker, Func editorFactory, Action showMessage) { - _taskRepo = taskRepo; - _tagRepo = tagRepo; - _listRepo = listRepo; + _dbFactory = dbFactory; _worker = worker; _editorFactory = editorFactory; _showMessage = showMessage; @@ -77,7 +74,9 @@ public partial class TaskListViewModel : ViewModelBase if (listId is not null) { - var list = await _listRepo.GetByIdAsync(listId); + using var context = _dbFactory.CreateDbContext(); + var listRepo = new ListRepository(context); + var list = await listRepo.GetByIdAsync(listId); ListName = list?.Name ?? "Tasks"; } else @@ -89,10 +88,12 @@ public partial class TaskListViewModel : ViewModelBase try { - var entities = await _taskRepo.GetByListAsync(listId); + using var context = _dbFactory.CreateDbContext(); + var taskRepo = new TaskRepository(context); + var entities = await taskRepo.GetByListIdAsync(listId); foreach (var e in entities) { - var tags = await _taskRepo.GetEffectiveTagsAsync(e.Id); + var tags = await taskRepo.GetEffectiveTagsAsync(e.Id); Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync)); } } @@ -110,8 +111,13 @@ public partial class TaskListViewModel : ViewModelBase var title = InlineAddTitle.Trim(); if (string.IsNullOrEmpty(title) || CurrentListId is null) return; - var list = await _listRepo.GetByIdAsync(CurrentListId); - var defaultCommitType = list?.DefaultCommitType ?? "chore"; + string defaultCommitType; + using (var context = _dbFactory.CreateDbContext()) + { + var listRepo = new ListRepository(context); + var list = await listRepo.GetByIdAsync(CurrentListId); + defaultCommitType = list?.DefaultCommitType ?? "chore"; + } var entity = new TaskEntity { @@ -125,8 +131,10 @@ public partial class TaskListViewModel : ViewModelBase try { - await _taskRepo.AddAsync(entity); - var tags = await _taskRepo.GetEffectiveTagsAsync(entity.Id); + using var context = _dbFactory.CreateDbContext(); + var taskRepo = new TaskRepository(context); + await taskRepo.AddAsync(entity); + var tags = await taskRepo.GetEffectiveTagsAsync(entity.Id); var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync); Tasks.Add(vm); SelectedTask = vm; @@ -141,9 +149,13 @@ public partial class TaskListViewModel : ViewModelBase [RelayCommand(CanExecute = nameof(CanAddTask))] private async Task AddTask() { - // Get list default commit type - var list = await _listRepo.GetByIdAsync(CurrentListId); - var defaultCommitType = list?.DefaultCommitType ?? "chore"; + string defaultCommitType; + using (var context = _dbFactory.CreateDbContext()) + { + var listRepo = new ListRepository(context); + var list = await listRepo.GetByIdAsync(CurrentListId); + defaultCommitType = list?.DefaultCommitType ?? "chore"; + } var editor = _editorFactory(); await editor.LoadAgentsAsync(_worker); @@ -159,15 +171,18 @@ public partial class TaskListViewModel : ViewModelBase try { - await _taskRepo.AddAsync(saved); + using var context = _dbFactory.CreateDbContext(); + var taskRepo = new TaskRepository(context); + var tagRepo = new TagRepository(context); + await taskRepo.AddAsync(saved); foreach (var tagName in editor.SelectedTagNames) { - var tagId = await _tagRepo.GetOrCreateAsync(tagName); - await _taskRepo.AddTagAsync(saved.Id, tagId); + var tagId = await tagRepo.GetOrCreateAsync(tagName); + await taskRepo.AddTagAsync(saved.Id, tagId); } - var tags = await _taskRepo.GetEffectiveTagsAsync(saved.Id); + var tags = await taskRepo.GetEffectiveTagsAsync(saved.Id); Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync)); // Auto wake-queue if agent+queued @@ -188,10 +203,17 @@ public partial class TaskListViewModel : ViewModelBase private async Task EditTask() { if (SelectedTask is null || CurrentListId is null) return; - var entity = await _taskRepo.GetByIdAsync(SelectedTask.Id); - if (entity is null) return; - var taskTags = await _taskRepo.GetTagsAsync(entity.Id); + TaskEntity? entity; + List taskTags; + using (var context = _dbFactory.CreateDbContext()) + { + var taskRepo = new TaskRepository(context); + entity = await taskRepo.GetByIdAsync(SelectedTask.Id); + if (entity is null) return; + taskTags = await taskRepo.GetTagsAsync(entity.Id); + } + var editor = _editorFactory(); await editor.LoadAgentsAsync(_worker); await editor.InitForEditAsync(entity, taskTags); @@ -206,18 +228,21 @@ public partial class TaskListViewModel : ViewModelBase try { - await _taskRepo.UpdateAsync(saved); + using var context = _dbFactory.CreateDbContext(); + var taskRepo = new TaskRepository(context); + var tagRepo = new TagRepository(context); + await taskRepo.UpdateAsync(saved); - var existingTags = await _taskRepo.GetTagsAsync(saved.Id); + var existingTags = await taskRepo.GetTagsAsync(saved.Id); foreach (var old in existingTags) - await _taskRepo.RemoveTagAsync(saved.Id, old.Id); + await taskRepo.RemoveTagAsync(saved.Id, old.Id); foreach (var tagName in editor.SelectedTagNames) { - var tagId = await _tagRepo.GetOrCreateAsync(tagName); - await _taskRepo.AddTagAsync(saved.Id, tagId); + var tagId = await tagRepo.GetOrCreateAsync(tagName); + await taskRepo.AddTagAsync(saved.Id, tagId); } - var newTags = await _taskRepo.GetEffectiveTagsAsync(saved.Id); + var newTags = await taskRepo.GetEffectiveTagsAsync(saved.Id); SelectedTask.Refresh(saved, newTags); } catch (Exception ex) @@ -232,7 +257,9 @@ public partial class TaskListViewModel : ViewModelBase if (SelectedTask is null) return; try { - await _taskRepo.DeleteAsync(SelectedTask.Id); + using var context = _dbFactory.CreateDbContext(); + var taskRepo = new TaskRepository(context); + await taskRepo.DeleteAsync(SelectedTask.Id); Tasks.Remove(SelectedTask); SelectedTask = null; } @@ -244,14 +271,16 @@ public partial class TaskListViewModel : ViewModelBase public async Task RefreshSingleAsync(string taskId) { - var entity = await _taskRepo.GetByIdAsync(taskId); + using var context = _dbFactory.CreateDbContext(); + var taskRepo = new TaskRepository(context); + var entity = await taskRepo.GetByIdAsync(taskId); var existing = Tasks.FirstOrDefault(t => t.Id == taskId); if (entity is null) { if (existing is not null) Tasks.Remove(existing); return; } - var tags = await _taskRepo.GetEffectiveTagsAsync(taskId); + var tags = await taskRepo.GetEffectiveTagsAsync(taskId); if (existing is not null) existing.Refresh(entity, tags); } @@ -270,14 +299,16 @@ public partial class TaskListViewModel : ViewModelBase private async Task ToggleDoneAsync(string taskId) { - var entity = await _taskRepo.GetByIdAsync(taskId); + using var context = _dbFactory.CreateDbContext(); + var taskRepo = new TaskRepository(context); + var entity = await taskRepo.GetByIdAsync(taskId); if (entity is null) return; entity.Status = entity.Status == TaskStatus.Done ? TaskStatus.Manual : TaskStatus.Done; if (entity.Status == TaskStatus.Done) entity.FinishedAt = DateTime.UtcNow; - await _taskRepo.UpdateAsync(entity); + await taskRepo.UpdateAsync(entity); await RefreshSingleAsync(taskId); } diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index dbef489..fbf8c9a 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -5,6 +5,7 @@ using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Services; +using Microsoft.EntityFrameworkCore; var cfg = WorkerConfig.Load(); @@ -14,18 +15,10 @@ var builder = WebApplication.CreateBuilder(args); // doesn't think we crashed (~30s timeout). No-op when running interactively. builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker"); -// Initialize DB schema before the host starts accepting connections. -var dbFactory = new SqliteConnectionFactory(cfg.DbPath); -SchemaInitializer.Apply(dbFactory); +builder.Services.AddDbContextFactory(opt => + opt.UseSqlite($"Data Source={cfg.DbPath}")); builder.Services.AddSingleton(cfg); -builder.Services.AddSingleton(dbFactory); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddSignalR(); @@ -51,6 +44,12 @@ builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}"); var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + ClaudeDoDbContext.MigrateAndConfigure( + scope.ServiceProvider.GetRequiredService()); +} + app.MapHub("/hub"); app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})", diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 2b13c51..8e03b6b 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -1,18 +1,16 @@ +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Runner; public sealed class TaskRunner { private readonly IClaudeProcess _claude; - private readonly TaskRepository _taskRepo; - private readonly TaskRunRepository _runRepo; - private readonly ListRepository _listRepo; - private readonly WorktreeRepository _wtRepo; - private readonly SubtaskRepository _subtaskRepo; + private readonly IDbContextFactory _dbFactory; private readonly HubBroadcaster _broadcaster; private readonly WorktreeManager _wtManager; private readonly ClaudeArgsBuilder _argsBuilder; @@ -21,11 +19,7 @@ public sealed class TaskRunner public TaskRunner( IClaudeProcess claude, - TaskRepository taskRepo, - TaskRunRepository runRepo, - ListRepository listRepo, - WorktreeRepository wtRepo, - SubtaskRepository subtaskRepo, + IDbContextFactory dbFactory, HubBroadcaster broadcaster, WorktreeManager wtManager, ClaudeArgsBuilder argsBuilder, @@ -33,11 +27,7 @@ public sealed class TaskRunner ILogger logger) { _claude = claude; - _taskRepo = taskRepo; - _runRepo = runRepo; - _listRepo = listRepo; - _wtRepo = wtRepo; - _subtaskRepo = subtaskRepo; + _dbFactory = dbFactory; _broadcaster = broadcaster; _wtManager = wtManager; _argsBuilder = argsBuilder; @@ -49,11 +39,23 @@ public sealed class TaskRunner { try { - var list = await _listRepo.GetByIdAsync(task.ListId, ct); - if (list is null) + ListEntity? list; + ListConfigEntity? listConfig; + List subtasks; + + using (var context = _dbFactory.CreateDbContext()) { - await MarkFailed(task.Id, slot, "List not found."); - return; + var listRepo = new ListRepository(context); + list = await listRepo.GetByIdAsync(task.ListId, ct); + if (list is null) + { + await MarkFailed(task.Id, slot, "List not found."); + return; + } + listConfig = await listRepo.GetConfigAsync(task.ListId, ct); + + var subtaskRepo = new SubtaskRepository(context); + subtasks = await subtaskRepo.GetByTaskIdAsync(task.Id, ct); } // Determine working directory: worktree or sandbox. @@ -81,7 +83,6 @@ public sealed class TaskRunner } // Resolve config: task overrides > list config > null. - var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct); var resolvedConfig = new ClaudeRunConfig( Model: task.Model ?? listConfig?.Model ?? "claude-sonnet-4-6", SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt, @@ -90,11 +91,14 @@ public sealed class TaskRunner ); var now = DateTime.UtcNow; - await _taskRepo.MarkRunningAsync(task.Id, now, ct); + using (var context = _dbFactory.CreateDbContext()) + { + var taskRepo = new TaskRepository(context); + await taskRepo.MarkRunningAsync(task.Id, now, ct); + } await _broadcaster.TaskStarted(slot, task.Id, now); // Build prompt. - var subtasks = await _subtaskRepo.GetByTaskIdAsync(task.Id, ct); var sb = new System.Text.StringBuilder(task.Title); if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim()); if (subtasks.Count > 0) @@ -155,19 +159,34 @@ public sealed class TaskRunner public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct) { - var task = await _taskRepo.GetByIdAsync(taskId, ct) - ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); + TaskEntity task; + TaskRunEntity lastRun; + ListEntity list; + ListConfigEntity? listConfig; + WorktreeEntity? worktree; - var lastRun = await _runRepo.GetLatestByTaskIdAsync(taskId, ct) - ?? throw new InvalidOperationException("No previous run to continue."); + using (var context = _dbFactory.CreateDbContext()) + { + var taskRepo = new TaskRepository(context); + task = await taskRepo.GetByIdAsync(taskId, ct) + ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); - if (lastRun.SessionId is null) - throw new InvalidOperationException("Previous run has no session ID — cannot resume."); + var runRepo = new TaskRunRepository(context); + lastRun = await runRepo.GetLatestByTaskIdAsync(taskId, ct) + ?? throw new InvalidOperationException("No previous run to continue."); - var list = await _listRepo.GetByIdAsync(task.ListId, ct) - ?? throw new InvalidOperationException("List not found."); + if (lastRun.SessionId is null) + throw new InvalidOperationException("Previous run has no session ID — cannot resume."); + + var listRepo = new ListRepository(context); + list = await listRepo.GetByIdAsync(task.ListId, ct) + ?? throw new InvalidOperationException("List not found."); + listConfig = await listRepo.GetConfigAsync(task.ListId, ct); + + var wtRepo = new WorktreeRepository(context); + worktree = await wtRepo.GetByTaskIdAsync(taskId, ct); + } - var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct); var resolvedConfig = new ClaudeRunConfig( Model: task.Model ?? listConfig?.Model, SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt, @@ -178,7 +197,6 @@ public sealed class TaskRunner // Determine run directory from existing worktree or sandbox. string runDir; WorktreeContext? wtCtx = null; - var worktree = await _wtRepo.GetByTaskIdAsync(taskId, ct); if (worktree is not null) { runDir = worktree.Path; @@ -190,7 +208,11 @@ public sealed class TaskRunner } var now = DateTime.UtcNow; - await _taskRepo.MarkRunningAsync(taskId, now, ct); + using (var context = _dbFactory.CreateDbContext()) + { + var taskRepo = new TaskRepository(context); + await taskRepo.MarkRunningAsync(taskId, now, ct); + } await _broadcaster.TaskStarted(slot, taskId, now); var nextRunNumber = lastRun.RunNumber + 1; @@ -226,7 +248,12 @@ public sealed class TaskRunner LogPath = logPath, StartedAt = DateTime.UtcNow, }; - await _runRepo.AddAsync(run, ct); + + using (var context = _dbFactory.CreateDbContext()) + { + var runRepo = new TaskRunRepository(context); + await runRepo.AddAsync(run, ct); + } var arguments = _argsBuilder.Build(config); @@ -257,10 +284,15 @@ public sealed class TaskRunner run.TokensIn = result.TokensIn; run.TokensOut = result.TokensOut; run.FinishedAt = DateTime.UtcNow; - await _runRepo.UpdateAsync(run, CancellationToken.None); - // Update denormalized fields on the task. - await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None); + using (var context = _dbFactory.CreateDbContext()) + { + var runRepo = new TaskRunRepository(context); + await runRepo.UpdateAsync(run, CancellationToken.None); + + var taskRepo = new TaskRepository(context); + await taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None); + } return result; } @@ -273,8 +305,12 @@ public sealed class TaskRunner run.FinishedAt = DateTime.UtcNow; try { - await _runRepo.UpdateAsync(run, CancellationToken.None); - await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None); + using var context = _dbFactory.CreateDbContext(); + var runRepo = new TaskRunRepository(context); + await runRepo.UpdateAsync(run, CancellationToken.None); + + var taskRepo = new TaskRepository(context); + await taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None); } catch (Exception updateEx) { @@ -297,7 +333,11 @@ public sealed class TaskRunner // is never left as 'running' because of a cancel that arrived // after the Claude run already succeeded. var finishedAt = DateTime.UtcNow; - await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None); + using (var context = _dbFactory.CreateDbContext()) + { + var taskRepo = new TaskRepository(context); + await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None); + } await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt); _logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})", task.Id, result.TurnCount, result.TokensIn, result.TokensOut); @@ -308,7 +348,9 @@ public sealed class TaskRunner // Intentionally does not accept a CancellationToken: this is the // terminal write for a failed task and must always be persisted. var finishedAt = DateTime.UtcNow; - await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None); + using var context = _dbFactory.CreateDbContext(); + var taskRepo = new TaskRepository(context); + await taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None); await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt); _logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown); } @@ -319,7 +361,9 @@ public sealed class TaskRunner { var now = DateTime.UtcNow; // Terminal write — never cancel. - await _taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None); + using var context = _dbFactory.CreateDbContext(); + var taskRepo = new TaskRepository(context); + await taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None); await _broadcaster.TaskFinished(slot, taskId, "failed", now); await _broadcaster.TaskUpdated(taskId); } diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index 51b76d6..a0869f9 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -1,7 +1,9 @@ +using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Runner; @@ -10,14 +12,14 @@ public sealed record WorktreeContext(string WorktreePath, string BranchName, str public sealed class WorktreeManager { private readonly GitService _git; - private readonly WorktreeRepository _wtRepo; + private readonly IDbContextFactory _dbFactory; private readonly WorkerConfig _cfg; private readonly ILogger _logger; - public WorktreeManager(GitService git, WorktreeRepository wtRepo, WorkerConfig cfg, ILogger logger) + public WorktreeManager(GitService git, IDbContextFactory dbFactory, WorkerConfig cfg, ILogger logger) { _git = git; - _wtRepo = wtRepo; + _dbFactory = dbFactory; _cfg = cfg; _logger = logger; } @@ -50,7 +52,9 @@ public sealed class WorktreeManager await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct); // Insert worktrees row AFTER git succeeds — if git throws, no row is created. - await _wtRepo.AddAsync(new WorktreeEntity + using var context = _dbFactory.CreateDbContext(); + var wtRepo = new WorktreeRepository(context); + await wtRepo.AddAsync(new WorktreeEntity { TaskId = task.Id, Path = worktreePath, @@ -87,7 +91,9 @@ public sealed class WorktreeManager var head = await _git.RevParseHeadAsync(ctx.WorktreePath, ct); var diffStat = await _git.DiffStatAsync(ctx.WorktreePath, ctx.BaseCommit, head, ct); - await _wtRepo.UpdateHeadAsync(task.Id, head, diffStat, ct); + using var context = _dbFactory.CreateDbContext(); + var wtRepo = new WorktreeRepository(context); + await wtRepo.UpdateHeadAsync(task.Id, head, diffStat, ct); _logger.LogInformation("Committed changes for task {TaskId}: {Head}", task.Id, head); return true; diff --git a/src/ClaudeDo.Worker/Services/QueueService.cs b/src/ClaudeDo.Worker/Services/QueueService.cs index 27b256d..8fbc456 100644 --- a/src/ClaudeDo.Worker/Services/QueueService.cs +++ b/src/ClaudeDo.Worker/Services/QueueService.cs @@ -1,7 +1,9 @@ +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Runner; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Services; @@ -14,7 +16,7 @@ public sealed class QueueSlotState public sealed class QueueService : BackgroundService { - private readonly TaskRepository _taskRepo; + private readonly IDbContextFactory _dbFactory; private readonly TaskRunner _runner; private readonly WorkerConfig _cfg; private readonly ILogger _logger; @@ -26,12 +28,12 @@ public sealed class QueueService : BackgroundService private readonly SemaphoreSlim _wakeSignal = new(0, 1); public QueueService( - TaskRepository taskRepo, + IDbContextFactory dbFactory, TaskRunner runner, WorkerConfig cfg, ILogger logger) { - _taskRepo = taskRepo; + _dbFactory = dbFactory; _runner = runner; _cfg = cfg; _logger = logger; @@ -56,7 +58,9 @@ public sealed class QueueService : BackgroundService public async Task RunNow(string taskId) { - var task = await _taskRepo.GetByIdAsync(taskId); + using var context = _dbFactory.CreateDbContext(); + var taskRepo = new TaskRepository(context); + var task = await taskRepo.GetByIdAsync(taskId); if (task is null) throw new KeyNotFoundException($"Task '{taskId}' not found."); @@ -78,7 +82,9 @@ public sealed class QueueService : BackgroundService public async Task ContinueTask(string taskId, string followUpPrompt) { - var task = await _taskRepo.GetByIdAsync(taskId) + using var context = _dbFactory.CreateDbContext(); + var taskRepo = new TaskRepository(context); + var task = await taskRepo.GetByIdAsync(taskId) ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); if (task.Status == Data.Models.TaskStatus.Running) @@ -144,7 +150,12 @@ public sealed class QueueService : BackgroundService if (_queueSlot is not null) continue; - var task = await _taskRepo.GetNextQueuedAgentTaskAsync(DateTime.UtcNow, stoppingToken); + TaskEntity? task; + using (var context = _dbFactory.CreateDbContext()) + { + var taskRepo = new TaskRepository(context); + task = await taskRepo.GetNextQueuedAgentTaskAsync(DateTime.UtcNow, stoppingToken); + } if (task is null) continue; lock (_lock) diff --git a/src/ClaudeDo.Worker/Services/StaleTaskRecovery.cs b/src/ClaudeDo.Worker/Services/StaleTaskRecovery.cs index aa43d66..7ad3df7 100644 --- a/src/ClaudeDo.Worker/Services/StaleTaskRecovery.cs +++ b/src/ClaudeDo.Worker/Services/StaleTaskRecovery.cs @@ -1,21 +1,25 @@ +using ClaudeDo.Data; using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Services; public sealed class StaleTaskRecovery : IHostedService { - private readonly TaskRepository _tasks; + private readonly IDbContextFactory _dbFactory; private readonly ILogger _logger; - public StaleTaskRecovery(TaskRepository tasks, ILogger logger) + public StaleTaskRecovery(IDbContextFactory dbFactory, ILogger logger) { - _tasks = tasks; + _dbFactory = dbFactory; _logger = logger; } public async Task StartAsync(CancellationToken cancellationToken) { - var flipped = await _tasks.FlipAllRunningToFailedAsync("worker restart", cancellationToken); + using var context = _dbFactory.CreateDbContext(); + var tasks = new TaskRepository(context); + var flipped = await tasks.FlipAllRunningToFailedAsync("worker restart", cancellationToken); if (flipped > 0) _logger.LogWarning("Stale task recovery: flipped {Count} running task(s) to failed", flipped); else diff --git a/tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj b/tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj index bfa12a3..71102ba 100644 --- a/tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj +++ b/tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs index ebd8ff5..d15c574 100644 --- a/tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs +++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs @@ -1,19 +1,30 @@ using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Tests.Infrastructure; public sealed class DbFixture : IDisposable { public string DbPath { get; } - public SqliteConnectionFactory Factory { get; } public DbFixture() { DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db"); - Factory = new SqliteConnectionFactory(DbPath); - SchemaInitializer.Apply(Factory); + // Apply migrations so the schema is created. + using var ctx = CreateContext(); + ctx.Database.Migrate(); } + public ClaudeDoDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={DbPath}") + .Options; + return new ClaudeDoDbContext(options); + } + + public TestDbContextFactory CreateFactory() => new(this); + public void Dispose() { try { File.Delete(DbPath); } catch { /* best effort */ } @@ -21,3 +32,10 @@ public sealed class DbFixture : IDisposable try { File.Delete(DbPath + "-shm"); } catch { } } } + +public sealed class TestDbContextFactory : IDbContextFactory +{ + private readonly DbFixture _fixture; + public TestDbContextFactory(DbFixture fixture) => _fixture = fixture; + public ClaudeDoDbContext CreateDbContext() => _fixture.CreateContext(); +} diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs index a9068f3..07e6b71 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs @@ -1,3 +1,4 @@ +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; @@ -7,12 +8,14 @@ namespace ClaudeDo.Worker.Tests.Repositories; public sealed class ListRepositoryConfigTests : IDisposable { private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; private readonly ListRepository _repo; private readonly string _listId; public ListRepositoryConfigTests() { - _repo = new ListRepository(_db.Factory); + _ctx = _db.CreateContext(); + _repo = new ListRepository(_ctx); _listId = Guid.NewGuid().ToString(); _repo.AddAsync(new ListEntity { @@ -57,5 +60,9 @@ public sealed class ListRepositoryConfigTests : IDisposable Assert.Equal("haiku-4-5", fetched.Model); } - public void Dispose() => _db.Dispose(); + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + } } diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryTests.cs index f1e931d..20463a2 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryTests.cs @@ -1,3 +1,4 @@ +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; @@ -7,16 +8,22 @@ namespace ClaudeDo.Worker.Tests.Repositories; public sealed class ListRepositoryTests : IDisposable { private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; private readonly ListRepository _lists; private readonly TagRepository _tags; public ListRepositoryTests() { - _lists = new ListRepository(_db.Factory); - _tags = new TagRepository(_db.Factory); + _ctx = _db.CreateContext(); + _lists = new ListRepository(_ctx); + _tags = new TagRepository(_ctx); } - public void Dispose() => _db.Dispose(); + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + } [Fact] public async Task AddAsync_And_GetByIdAsync_Roundtrips() diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs index e99c376..1495213 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs @@ -1,3 +1,4 @@ +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; @@ -8,18 +9,24 @@ namespace ClaudeDo.Worker.Tests.Repositories; public sealed class TaskRepositoryTests : IDisposable { private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly TagRepository _tags; public TaskRepositoryTests() { - _tasks = new TaskRepository(_db.Factory); - _lists = new ListRepository(_db.Factory); - _tags = new TagRepository(_db.Factory); + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _tags = new TagRepository(_ctx); } - public void Dispose() => _db.Dispose(); + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + } private async Task CreateListAsync(string? id = null) { @@ -197,7 +204,7 @@ public sealed class TaskRepositoryTests : IDisposable var listId = await CreateListAsync(); var agentTagId = await _tags.GetOrCreateAsync("agent"); var manualTagId = await _tags.GetOrCreateAsync("manual"); - var codeTagId = await TagRepository.GetOrCreateAsync(_db.Factory.Open(), "code"); + var codeTagId = await _tags.GetOrCreateAsync("code"); await _lists.AddTagAsync(listId, agentTagId); diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs index 0d06bb1..0205d51 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs @@ -1,3 +1,4 @@ +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; @@ -7,16 +8,18 @@ namespace ClaudeDo.Worker.Tests.Repositories; public sealed class TaskRunRepositoryTests : IDisposable { private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; private readonly TaskRunRepository _runs; private readonly string _taskId; public TaskRunRepositoryTests() { - _runs = new TaskRunRepository(_db.Factory); + _ctx = _db.CreateContext(); + _runs = new TaskRunRepository(_ctx); // Seed a list and task for all tests - var lists = new ListRepository(_db.Factory); - var tasks = new TaskRepository(_db.Factory); + var lists = new ListRepository(_ctx); + var tasks = new TaskRepository(_ctx); var listId = Guid.NewGuid().ToString(); lists.AddAsync(new ListEntity { @@ -37,7 +40,11 @@ public sealed class TaskRunRepositoryTests : IDisposable }).GetAwaiter().GetResult(); } - public void Dispose() => _db.Dispose(); + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + } private TaskRunEntity MakeRun(int runNumber, bool isRetry = false) => new() { diff --git a/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs index 7e5ed68..4e28ec9 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs @@ -1,3 +1,4 @@ +using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; @@ -24,19 +25,19 @@ public class WorktreeManagerTests : IDisposable return f; } - private async Task<(WorktreeManager mgr, WorktreeRepository wtRepo)> CreateManagerAsync( + private async Task<(WorktreeManager mgr, DbFixture db)> CreateManagerAsync( TaskEntity task, ListEntity list, string strategy = "sibling", string? centralRoot = null) { var db = new DbFixture(); _dbFixtures.Add(db); // Seed the DB with list and task so FK constraints pass. - var listRepo = new ListRepository(db.Factory); - var taskRepo = new TaskRepository(db.Factory); + using var seedCtx = db.CreateContext(); + var listRepo = new ListRepository(seedCtx); + var taskRepo = new TaskRepository(seedCtx); await listRepo.AddAsync(list); await taskRepo.AddAsync(task); - var wtRepo = new WorktreeRepository(db.Factory); var cfg = new WorkerConfig { WorktreeRootStrategy = strategy, @@ -45,8 +46,8 @@ public class WorktreeManagerTests : IDisposable cfg.CentralWorktreeRoot = centralRoot; var mgr = new WorktreeManager( - new GitService(), wtRepo, cfg, NullLogger.Instance); - return (mgr, wtRepo); + new GitService(), db.CreateFactory(), cfg, NullLogger.Instance); + return (mgr, db); } [Fact] @@ -56,7 +57,7 @@ public class WorktreeManagerTests : IDisposable var repo = CreateRepo(); var (task, list) = MakeEntities(repo.RepoDir); - var (mgr, wtRepo) = await CreateManagerAsync(task, list); + var (mgr, db) = await CreateManagerAsync(task, list); var ctx = await mgr.CreateAsync(task, list, CancellationToken.None); _worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath)); @@ -66,6 +67,8 @@ public class WorktreeManagerTests : IDisposable Assert.Equal($"claudedo/{task.Id.Replace("-", "")}", ctx.BranchName); Assert.Equal(repo.BaseCommit, ctx.BaseCommit); + using var readCtx = db.CreateContext(); + var wtRepo = new WorktreeRepository(readCtx); var row = await wtRepo.GetByTaskIdAsync(task.Id); Assert.NotNull(row); Assert.Equal(WorktreeState.Active, row!.State); @@ -80,7 +83,7 @@ public class WorktreeManagerTests : IDisposable var repo = CreateRepo(); var (task, list) = MakeEntities(repo.RepoDir); - var (mgr, wtRepo) = await CreateManagerAsync(task, list); + var (mgr, db) = await CreateManagerAsync(task, list); var ctx = await mgr.CreateAsync(task, list, CancellationToken.None); _worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath)); @@ -88,6 +91,8 @@ public class WorktreeManagerTests : IDisposable var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None); Assert.False(committed); + using var readCtx = db.CreateContext(); + var wtRepo = new WorktreeRepository(readCtx); var row = await wtRepo.GetByTaskIdAsync(task.Id); Assert.Null(row!.HeadCommit); } @@ -99,7 +104,7 @@ public class WorktreeManagerTests : IDisposable var repo = CreateRepo(); var (task, list) = MakeEntities(repo.RepoDir); - var (mgr, wtRepo) = await CreateManagerAsync(task, list); + var (mgr, db) = await CreateManagerAsync(task, list); var ctx = await mgr.CreateAsync(task, list, CancellationToken.None); _worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath)); @@ -109,6 +114,8 @@ public class WorktreeManagerTests : IDisposable var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None); Assert.True(committed); + using var readCtx = db.CreateContext(); + var wtRepo = new WorktreeRepository(readCtx); var row = await wtRepo.GetByTaskIdAsync(task.Id); Assert.NotNull(row!.HeadCommit); Assert.NotEqual(ctx.BaseCommit, row.HeadCommit); @@ -129,20 +136,24 @@ public class WorktreeManagerTests : IDisposable var db = new DbFixture(); _dbFixtures.Add(db); - var listRepo = new ListRepository(db.Factory); - var taskRepo = new TaskRepository(db.Factory); - await listRepo.AddAsync(list); - await taskRepo.AddAsync(task); + using (var seedCtx = db.CreateContext()) + { + var listRepo = new ListRepository(seedCtx); + var taskRepo = new TaskRepository(seedCtx); + await listRepo.AddAsync(list); + await taskRepo.AddAsync(task); + } - var wtRepo = new WorktreeRepository(db.Factory); var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; var mgr = new WorktreeManager( - new GitService(), wtRepo, cfg, NullLogger.Instance); + new GitService(), db.CreateFactory(), cfg, NullLogger.Instance); var ex = await Assert.ThrowsAsync( () => mgr.CreateAsync(task, list, CancellationToken.None)); Assert.Contains("not a git repository", ex.Message); + using var readCtx = db.CreateContext(); + var wtRepo = new WorktreeRepository(readCtx); var row = await wtRepo.GetByTaskIdAsync(task.Id); Assert.Null(row); } diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs index fc2fe1d..3b5a88a 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs @@ -1,3 +1,4 @@ +using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; @@ -15,6 +16,7 @@ namespace ClaudeDo.Worker.Tests.Services; public sealed class QueueServiceTests : IDisposable { private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _taskRepo; private readonly ListRepository _listRepo; private readonly TagRepository _tagRepo; @@ -23,9 +25,10 @@ public sealed class QueueServiceTests : IDisposable public QueueServiceTests() { - _taskRepo = new TaskRepository(_db.Factory); - _listRepo = new ListRepository(_db.Factory); - _tagRepo = new TagRepository(_db.Factory); + _ctx = _db.CreateContext(); + _taskRepo = new TaskRepository(_ctx); + _listRepo = new ListRepository(_ctx); + _tagRepo = new TagRepository(_ctx); _tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); _cfg = new WorkerConfig @@ -38,6 +41,7 @@ public sealed class QueueServiceTests : IDisposable public void Dispose() { + _ctx.Dispose(); _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } } @@ -47,14 +51,12 @@ public sealed class QueueServiceTests : IDisposable { var fake = new FakeClaudeProcess(handler); var broadcaster = new HubBroadcaster(new FakeHubContext()); - var wtRepo = new WorktreeRepository(_db.Factory); - var runRepo = new TaskRunRepository(_db.Factory); - var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger.Instance); + var dbFactory = _db.CreateFactory(); + var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); var argsBuilder = new ClaudeArgsBuilder(); - var subtaskRepo = new SubtaskRepository(_db.Factory); - var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, subtaskRepo, broadcaster, wtManager, argsBuilder, _cfg, + var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, NullLogger.Instance); - var service = new QueueService(_taskRepo, runner, _cfg, NullLogger.Instance); + var service = new QueueService(dbFactory, runner, _cfg, NullLogger.Instance); return (service, fake); } diff --git a/tests/ClaudeDo.Worker.Tests/Services/StaleTaskRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Services/StaleTaskRecoveryTests.cs index 20d54ca..f6452d6 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/StaleTaskRecoveryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/StaleTaskRecoveryTests.cs @@ -1,3 +1,4 @@ +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Services; @@ -10,16 +11,22 @@ namespace ClaudeDo.Worker.Tests.Services; public sealed class StaleTaskRecoveryTests : IDisposable { private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; public StaleTaskRecoveryTests() { - _tasks = new TaskRepository(_db.Factory); - _lists = new ListRepository(_db.Factory); + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); } - public void Dispose() => _db.Dispose(); + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + } [Fact] public async Task StartAsync_Flips_Running_Tasks_To_Failed() @@ -47,7 +54,7 @@ public sealed class StaleTaskRecoveryTests : IDisposable await _tasks.AddAsync(running); await _tasks.AddAsync(queued); - var recovery = new StaleTaskRecovery(_tasks, NullLogger.Instance); + var recovery = new StaleTaskRecovery(_db.CreateFactory(), NullLogger.Instance); await recovery.StartAsync(CancellationToken.None); var r = await _tasks.GetByIdAsync(running.Id);