Merge branch 'feat/subtask-tree-view'

This commit is contained in:
mika kuns
2026-04-16 12:17:46 +02:00
54 changed files with 2102 additions and 1171 deletions

1
.gitignore vendored
View File

@@ -61,3 +61,4 @@ Desktop.ini
*.log
*.tmp
*.bak
design-time.db

View File

@@ -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<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` 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

View File

@@ -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');

View File

@@ -5,22 +5,32 @@ using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Runtime.InteropServices;
namespace ClaudeDo.App;
sealed class Program
{
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SetCurrentProcessExplicitAppUserModelID(string appId);
[STAThread]
public static void Main(string[] args)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App");
var services = BuildServices();
App.Services = services;
// Ensure DB schema exists
var factory = services.GetRequiredService<SqliteConnectionFactory>();
SchemaInitializer.Apply(factory);
using (var scope = services.CreateScope())
{
ClaudeDoDbContext.MigrateAndConfigure(
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
}
try
{
@@ -55,14 +65,10 @@ sealed class Program
// Infrastructure
sc.AddSingleton(settings);
sc.AddSingleton(new SqliteConnectionFactory(dbPath));
// Repositories
sc.AddSingleton<ListRepository>();
sc.AddSingleton<TaskRepository>();
sc.AddSingleton<SubtaskRepository>();
sc.AddSingleton<TagRepository>();
sc.AddSingleton<WorktreeRepository>();
sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={dbPath}"));
sc.AddScoped<ClaudeDoDbContext>(sp =>
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
// Services
sc.AddSingleton<GitService>();
@@ -72,30 +78,21 @@ sealed class Program
sc.AddTransient<ListEditorViewModel>();
sc.AddTransient<TaskEditorViewModel>();
sc.AddSingleton<StatusBarViewModel>();
sc.AddSingleton<TaskDetailViewModel>(sp => new TaskDetailViewModel(
sp.GetRequiredService<TaskRepository>(),
sp.GetRequiredService<WorktreeRepository>(),
sp.GetRequiredService<ListRepository>(),
sp.GetRequiredService<GitService>(),
sp.GetRequiredService<WorkerClient>(),
sp.GetRequiredService<TagRepository>(),
sp.GetRequiredService<SubtaskRepository>()));
sc.AddSingleton<TaskDetailViewModel>();
sc.AddSingleton<TaskListViewModel>(sp =>
{
var taskRepo = sp.GetRequiredService<TaskRepository>();
var tagRepo = sp.GetRequiredService<TagRepository>();
var listRepo = sp.GetRequiredService<ListRepository>();
var dbFactory = sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>();
var worker = sp.GetRequiredService<WorkerClient>();
var statusBar = sp.GetRequiredService<StatusBarViewModel>();
return new TaskListViewModel(
taskRepo, tagRepo, listRepo, worker,
dbFactory, worker,
() => sp.GetRequiredService<TaskEditorViewModel>(),
msg => statusBar.ShowMessage(msg));
});
sc.AddSingleton<MainWindowViewModel>(sp =>
{
return new MainWindowViewModel(
sp.GetRequiredService<ListRepository>(),
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<WorkerClient>(),
sp.GetRequiredService<TaskListViewModel>(),
sp.GetRequiredService<TaskDetailViewModel>(),

View File

@@ -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<ClaudeDoDbContext>** — 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<T>`)
- 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

View File

@@ -7,11 +7,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\..\schema\schema.sql" Link="schema.sql" LogicalName="ClaudeDo.Data.schema.sql" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -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<ClaudeDoDbContext> options) : base(options) { }
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<TagEntity> Tags => Set<TagEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly);
}
/// <summary>
/// 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.
/// </summary>
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");
}
}

View File

@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace ClaudeDo.Data;
public sealed class ClaudeDoDbContextFactory : IDesignTimeDbContextFactory<ClaudeDoDbContext>
{
public ClaudeDoDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite("Data Source=design-time.db")
.Options;
return new ClaudeDoDbContext(options);
}
}

View File

@@ -0,0 +1,19 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfigEntity>
{
public void Configure(EntityTypeBuilder<ListConfigEntity> builder)
{
builder.ToTable("list_config");
builder.HasKey(c => c.ListId);
builder.Property(c => c.ListId).HasColumnName("list_id");
builder.Property(c => c.Model).HasColumnName("model");
builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt");
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
}
}

View File

@@ -0,0 +1,36 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
{
public void Configure(EntityTypeBuilder<ListEntity> builder)
{
builder.ToTable("lists");
builder.HasKey(l => l.Id);
builder.Property(l => l.Id).HasColumnName("id");
builder.Property(l => l.Name).HasColumnName("name").IsRequired();
builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
builder.HasOne(l => l.Config)
.WithOne(c => c.List)
.HasForeignKey<ListConfigEntity>(c => c.ListId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(l => l.Tags)
.WithMany(tag => tag.Lists)
.UsingEntity("list_tags",
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
r => r.HasOne(typeof(ListEntity)).WithMany().HasForeignKey("list_id").OnDelete(DeleteBehavior.Cascade),
j =>
{
j.HasKey("list_id", "tag_id");
j.ToTable("list_tags");
});
}
}

View File

@@ -0,0 +1,28 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class SubtaskEntityConfiguration : IEntityTypeConfiguration<SubtaskEntity>
{
public void Configure(EntityTypeBuilder<SubtaskEntity> builder)
{
builder.ToTable("subtasks");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id");
builder.Property(s => s.TaskId).HasColumnName("task_id").IsRequired();
builder.Property(s => s.Title).HasColumnName("title").IsRequired();
builder.Property(s => s.Completed).HasColumnName("completed").IsRequired().HasDefaultValue(false);
builder.Property(s => s.OrderNum).HasColumnName("order_num").IsRequired();
builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired();
builder.HasOne(s => s.Task)
.WithMany(t => t.Subtasks)
.HasForeignKey(s => s.TaskId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(s => s.TaskId).HasDatabaseName("idx_subtasks_task_id");
}
}

View File

@@ -0,0 +1,22 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class TagEntityConfiguration : IEntityTypeConfiguration<TagEntity>
{
public void Configure(EntityTypeBuilder<TagEntity> builder)
{
builder.ToTable("tags");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id").ValueGeneratedOnAdd();
builder.Property(t => t.Name).HasColumnName("name").IsRequired();
builder.HasIndex(t => t.Name).IsUnique();
builder.HasData(
new TagEntity { Id = 1, Name = "agent" },
new TagEntity { Id = 2, Name = "manual" });
}
}

View File

@@ -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<TaskEntity>
{
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<TaskStatus, string> StatusConverter =
new(v => StatusToString(v), v => StatusFromString(v));
public void Configure(EntityTypeBuilder<TaskEntity> builder)
{
builder.ToTable("tasks");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id");
builder.Property(t => t.ListId).HasColumnName("list_id").IsRequired();
builder.Property(t => t.Title).HasColumnName("title").IsRequired();
builder.Property(t => t.Description).HasColumnName("description");
builder.Property(t => t.Status).HasColumnName("status").IsRequired()
.HasConversion(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<WorktreeEntity>(w => w.TaskId);
builder.HasMany(t => t.Tags)
.WithMany(tag => tag.Tasks)
.UsingEntity("task_tags",
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
r => r.HasOne(typeof(TaskEntity)).WithMany().HasForeignKey("task_id").OnDelete(DeleteBehavior.Cascade),
j =>
{
j.HasKey("task_id", "tag_id");
j.ToTable("task_tags");
});
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
}
}

View File

@@ -0,0 +1,38 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class TaskRunEntityConfiguration : IEntityTypeConfiguration<TaskRunEntity>
{
public void Configure(EntityTypeBuilder<TaskRunEntity> builder)
{
builder.ToTable("task_runs");
builder.HasKey(r => r.Id);
builder.Property(r => r.Id).HasColumnName("id");
builder.Property(r => r.TaskId).HasColumnName("task_id").IsRequired();
builder.Property(r => r.RunNumber).HasColumnName("run_number").IsRequired();
builder.Property(r => r.SessionId).HasColumnName("session_id");
builder.Property(r => r.IsRetry).HasColumnName("is_retry").IsRequired().HasDefaultValue(false);
builder.Property(r => r.Prompt).HasColumnName("prompt").IsRequired();
builder.Property(r => r.ResultMarkdown).HasColumnName("result_markdown");
builder.Property(r => r.StructuredOutputJson).HasColumnName("structured_output");
builder.Property(r => r.ErrorMarkdown).HasColumnName("error_markdown");
builder.Property(r => r.ExitCode).HasColumnName("exit_code");
builder.Property(r => r.TurnCount).HasColumnName("turn_count");
builder.Property(r => r.TokensIn).HasColumnName("tokens_in");
builder.Property(r => r.TokensOut).HasColumnName("tokens_out");
builder.Property(r => r.LogPath).HasColumnName("log_path");
builder.Property(r => r.StartedAt).HasColumnName("started_at");
builder.Property(r => r.FinishedAt).HasColumnName("finished_at");
builder.HasOne(r => r.Task)
.WithMany(t => t.Runs)
.HasForeignKey(r => r.TaskId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(r => r.TaskId).HasDatabaseName("idx_task_runs_task_id");
}
}

View File

@@ -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<WorktreeEntity>
{
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<WorktreeState, string> StateConverter =
new(v => StateToString(v), v => StateFromString(v));
public void Configure(EntityTypeBuilder<WorktreeEntity> builder)
{
builder.ToTable("worktrees");
builder.HasKey(w => w.TaskId);
builder.Property(w => w.TaskId).HasColumnName("task_id");
builder.Property(w => w.Path).HasColumnName("path").IsRequired();
builder.Property(w => w.BranchName).HasColumnName("branch_name").IsRequired();
builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired();
builder.Property(w => w.HeadCommit).HasColumnName("head_commit");
builder.Property(w => w.DiffStat).HasColumnName("diff_stat");
builder.Property(w => w.State).HasColumnName("state").IsRequired()
.HasDefaultValue(WorktreeState.Active)
.HasConversion(StateConverter);
builder.Property(w => w.CreatedAt).HasColumnName("created_at").IsRequired();
}
}

View File

@@ -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
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "lists",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
working_dir = table.Column<string>(type: "TEXT", nullable: true),
default_commit_type = table.Column<string>(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<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
name = table.Column<string>(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<string>(type: "TEXT", nullable: false),
model = table.Column<string>(type: "TEXT", nullable: true),
system_prompt = table.Column<string>(type: "TEXT", nullable: true),
agent_path = table.Column<string>(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<string>(type: "TEXT", nullable: false),
list_id = table.Column<string>(type: "TEXT", nullable: false),
title = table.Column<string>(type: "TEXT", nullable: false),
description = table.Column<string>(type: "TEXT", nullable: true),
status = table.Column<string>(type: "TEXT", nullable: false),
scheduled_for = table.Column<DateTime>(type: "TEXT", nullable: true),
result = table.Column<string>(type: "TEXT", nullable: true),
log_path = table.Column<string>(type: "TEXT", nullable: true),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
started_at = table.Column<DateTime>(type: "TEXT", nullable: true),
finished_at = table.Column<DateTime>(type: "TEXT", nullable: true),
commit_type = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "chore"),
model = table.Column<string>(type: "TEXT", nullable: true),
system_prompt = table.Column<string>(type: "TEXT", nullable: true),
agent_path = table.Column<string>(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<string>(type: "TEXT", nullable: false),
tag_id = table.Column<long>(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<string>(type: "TEXT", nullable: false),
task_id = table.Column<string>(type: "TEXT", nullable: false),
title = table.Column<string>(type: "TEXT", nullable: false),
completed = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
order_num = table.Column<int>(type: "INTEGER", nullable: false),
created_at = table.Column<DateTime>(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<string>(type: "TEXT", nullable: false),
task_id = table.Column<string>(type: "TEXT", nullable: false),
run_number = table.Column<int>(type: "INTEGER", nullable: false),
session_id = table.Column<string>(type: "TEXT", nullable: true),
is_retry = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
prompt = table.Column<string>(type: "TEXT", nullable: false),
result_markdown = table.Column<string>(type: "TEXT", nullable: true),
structured_output = table.Column<string>(type: "TEXT", nullable: true),
error_markdown = table.Column<string>(type: "TEXT", nullable: true),
exit_code = table.Column<int>(type: "INTEGER", nullable: true),
turn_count = table.Column<int>(type: "INTEGER", nullable: true),
tokens_in = table.Column<int>(type: "INTEGER", nullable: true),
tokens_out = table.Column<int>(type: "INTEGER", nullable: true),
log_path = table.Column<string>(type: "TEXT", nullable: true),
started_at = table.Column<DateTime>(type: "TEXT", nullable: true),
finished_at = table.Column<DateTime>(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<string>(type: "TEXT", nullable: false),
tag_id = table.Column<long>(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<string>(type: "TEXT", nullable: false),
path = table.Column<string>(type: "TEXT", nullable: false),
branch_name = table.Column<string>(type: "TEXT", nullable: false),
base_commit = table.Column<string>(type: "TEXT", nullable: false),
head_commit = table.Column<string>(type: "TEXT", nullable: true),
diff_stat = table.Column<string>(type: "TEXT", nullable: true),
state = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "active"),
created_at = table.Column<DateTime>(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");
}
/// <inheritdoc />
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");
}
}
}

View File

@@ -0,0 +1,479 @@
// <auto-generated />
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<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("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<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("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<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("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<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("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<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("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
}
}
}

View File

@@ -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!;
}

View File

@@ -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<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
}

View File

@@ -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!;
}

View File

@@ -4,4 +4,8 @@ public sealed class TagEntity
{
public long Id { get; init; }
public required string Name { get; set; }
// Navigation properties
public ICollection<ListEntity> Lists { get; set; } = new List<ListEntity>();
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
}

View File

@@ -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<TagEntity> Tags { get; set; } = new List<TagEntity>();
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
}

View File

@@ -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!;
}

View File

@@ -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!;
}

View File

@@ -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<ListEntity?> 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<List<ListEntity>> 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<ListEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadList(reader));
return result;
return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct);
}
public async Task<List<TagEntity>> 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<TagEntity>();
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<ListConfigEntity?> 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);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return new ListConfigEntity
{
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),
};
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
}
public async Task SetConfigAsync(ListConfigEntity entity, CancellationToken ct = default)
public async Task SetConfigAsync(ListConfigEntity config, 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()
var existing = await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == config.ListId, ct);
if (existing is null)
{
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),
};
_context.ListConfigs.Add(config);
}
else
{
existing.Model = config.Model;
existing.SystemPrompt = config.SystemPrompt;
existing.AgentPath = config.AgentPath;
}
await _context.SaveChangesAsync(ct);
}
}

View File

@@ -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<List<SubtaskEntity>> 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<SubtaskEntity>();
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<List<SubtaskEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{
return await _context.Subtasks
.Where(s => s.TaskId == taskId)
.OrderBy(s => s.OrderNum)
.ToListAsync(ct);
}
public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default)
{
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)),
};
}

View File

@@ -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<List<TagEntity>> 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<TagEntity>();
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<long> GetOrCreateAsync(string name, CancellationToken ct = default)
{
await using var conn = _factory.Open();
return await GetOrCreateAsync(conn, name, ct);
}
public static async Task<long> 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;
}
}

View File

@@ -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<TaskEntity?> 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<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
public async Task<List<TaskEntity>> 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<TaskEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadTask(reader));
return result;
// Kept for backwards-compatibility with callers using the old name.
public Task<List<TaskEntity>> 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<int> 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<List<TagEntity>> 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<TagEntity>();
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<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
{
return await _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags)
.ToListAsync(ct);
}
public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default)
{
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<TagEntity>();
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<TaskEntity?> 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 (
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'
UNION
)
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<int> 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
}

View File

@@ -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<TaskRunEntity?> GetByIdAsync(string runId, CancellationToken ct = default)
public async Task<TaskRunEntity?> 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<List<TaskRunEntity>> 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<TaskRunEntity>();
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<TaskRunEntity?> 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
}

View File

@@ -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<WorktreeEntity?> 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)),
};
}

View File

@@ -1,67 +0,0 @@
using System.Reflection;
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data;
/// <summary>
/// Applies the embedded schema.sql script. Safe to call on every start — the script uses
/// IF NOT EXISTS / INSERT OR IGNORE.
/// </summary>
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();
}
}

View File

@@ -1,48 +0,0 @@
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data;
/// <summary>
/// Opens <see cref="SqliteConnection"/> instances pointed at <see cref="DbPath"/>.
/// First call ensures the parent directory exists, enables WAL and foreign keys.
/// </summary>
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;
}
}

View File

@@ -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<ClaudeDoDbContext>()
.UseSqlite($"Data Source={expandedPath}")
.Options;
using var context = new ClaudeDoDbContext(options);
ClaudeDoDbContext.MigrateAndConfigure(context);
progress.Report("Schema applied successfully");
return Task.FromResult(StepResult.Ok());

View File

@@ -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<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker;
private readonly Func<ListEditorViewModel> _listEditorFactory;
@@ -26,14 +28,14 @@ public partial class MainWindowViewModel : ViewModelBase
public StatusBarViewModel StatusBar { get; }
public MainWindowViewModel(
ListRepository listRepo,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorkerClient worker,
TaskListViewModel taskList,
TaskDetailViewModel taskDetail,
StatusBarViewModel statusBar,
Func<ListEditorViewModel> 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;
}

View File

@@ -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<ClaudeDoDbContext> _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<string>? TaskChanged;
public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo,
ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo,
SubtaskRepository subtaskRepo)
public TaskDetailViewModel(IDbContextFactory<ClaudeDoDbContext> 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,10 +90,26 @@ public partial class TaskDetailViewModel : ViewModelBase
try
{
var task = await _taskRepo.GetByIdAsync(taskId, ct);
TaskEntity? task;
List<TagEntity> tags;
List<SubtaskEntity> 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)
{
var agents = await _worker.GetAgentsAsync();
@@ -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<Data.Models.TaskStatus>(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);
}

View File

@@ -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<ClaudeDoDbContext> _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<ClaudeDoDbContext> 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)
{
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 });
await subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
}
}
_tcs.TrySetResult(entity);

View File

@@ -1,7 +1,11 @@
using System.Collections.ObjectModel;
using Avalonia.Media;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels;
@@ -15,6 +19,11 @@ public partial class TaskItemViewModel : ViewModelBase
[ObservableProperty] private string? _description;
[ObservableProperty] private TaskStatus _status;
[ObservableProperty] private bool _isStarting;
[ObservableProperty] private bool _isExpanded;
[ObservableProperty] private bool _hasSubtasks;
[ObservableProperty] private int _subtaskCount;
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
public string Id { get; }
public string ListId { get; }
@@ -23,9 +32,13 @@ public partial class TaskItemViewModel : ViewModelBase
private readonly Func<string, Task>? _runNow;
private readonly Func<bool> _canRunNow;
private readonly Func<string, Task>? _toggleDone;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private bool _subtasksLoaded;
public TaskItemViewModel(TaskEntity entity, IReadOnlyList<TagEntity> tags,
Func<string, Task>? runNow, Func<bool> canRunNow, Func<string, Task>? toggleDone = null)
Func<string, Task>? runNow, Func<bool> canRunNow,
IDbContextFactory<ClaudeDoDbContext> dbFactory, int subtaskCount,
Func<string, Task>? toggleDone = null)
{
Entity = entity;
Id = entity.Id;
@@ -39,6 +52,9 @@ public partial class TaskItemViewModel : ViewModelBase
_runNow = runNow;
_canRunNow = canRunNow;
_toggleDone = toggleDone;
_dbFactory = dbFactory;
_subtaskCount = subtaskCount;
_hasSubtasks = subtaskCount > 0;
}
public bool IsDone => Status == TaskStatus.Done;
@@ -104,4 +120,55 @@ public partial class TaskItemViewModel : ViewModelBase
if (_toggleDone is not null)
await _toggleDone(Id);
}
[RelayCommand]
private async Task ToggleExpanded()
{
IsExpanded = !IsExpanded;
if (IsExpanded && !_subtasksLoaded)
await LoadSubtasksAsync();
}
private async Task LoadSubtasksAsync()
{
using var context = _dbFactory.CreateDbContext();
var repo = new SubtaskRepository(context);
var entities = await repo.GetByTaskIdAsync(Id);
Subtasks.Clear();
foreach (var e in entities)
Subtasks.Add(SubtaskItemViewModel.From(e));
_subtasksLoaded = true;
}
[RelayCommand]
private async Task ToggleSubtaskDone(string subtaskId)
{
var vm = Subtasks.FirstOrDefault(s => s.Id == subtaskId);
if (vm is null) return;
vm.Completed = !vm.Completed;
using var context = _dbFactory.CreateDbContext();
var entity = await context.Subtasks.FindAsync(subtaskId);
if (entity is not null)
{
entity.Completed = vm.Completed;
await context.SaveChangesAsync();
}
}
public async Task RefreshSubtasksAsync(int newCount)
{
SubtaskCount = newCount;
HasSubtasks = newCount > 0;
if (!HasSubtasks)
{
IsExpanded = false;
Subtasks.Clear();
_subtasksLoaded = false;
}
else if (_subtasksLoaded || IsExpanded)
{
await LoadSubtasksAsync();
}
}
}

View File

@@ -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<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker;
private readonly Func<TaskEditorViewModel> _editorFactory;
private readonly Action<string> _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<ClaudeDoDbContext> dbFactory, WorkerClient worker,
Func<TaskEditorViewModel> editorFactory, Action<string> 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,11 +88,20 @@ 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);
var taskIds = entities.Select(e => e.Id).ToList();
var subtaskCounts = await context.Subtasks
.Where(s => taskIds.Contains(s.TaskId))
.GroupBy(s => s.TaskId)
.ToDictionaryAsync(g => g.Key, g => g.Count());
foreach (var e in entities)
{
var tags = await _taskRepo.GetEffectiveTagsAsync(e.Id);
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync));
var tags = await taskRepo.GetEffectiveTagsAsync(e.Id);
subtaskCounts.TryGetValue(e.Id, out var count);
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, count, ToggleDoneAsync));
}
}
catch (Exception ex)
@@ -110,8 +118,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,9 +138,12 @@ public partial class TaskListViewModel : ViewModelBase
try
{
await _taskRepo.AddAsync(entity);
var tags = await _taskRepo.GetEffectiveTagsAsync(entity.Id);
var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync);
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,
_dbFactory, 0, ToggleDoneAsync);
Tasks.Add(vm);
SelectedTask = vm;
InlineAddTitle = "";
@@ -141,9 +157,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,16 +179,20 @@ 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);
Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync));
var tags = await taskRepo.GetEffectiveTagsAsync(saved.Id);
Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, 0, ToggleDoneAsync));
// Auto wake-queue if agent+queued
if (saved.Status == TaskStatus.Queued &&
@@ -188,10 +212,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<TagEntity> 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 +237,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 +266,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,16 +280,22 @@ 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);
var subtaskCount = await context.Subtasks.CountAsync(s => s.TaskId == taskId);
await existing.RefreshSubtasksAsync(subtaskCount);
}
}
private async Task RunNowAsync(string taskId)
@@ -270,14 +312,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);
}

View File

@@ -31,9 +31,11 @@
KeyDown="OnTaskListKeyDown">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TaskItemViewModel">
<Grid ColumnDefinitions="Auto,*" Margin="4,4"
<Grid RowDefinitions="Auto,Auto"
Background="Transparent"
Opacity="{Binding RowOpacity}"
Opacity="{Binding RowOpacity}">
<!-- Row 0: Task row -->
<Grid Grid.Row="0" ColumnDefinitions="20,Auto,*" Margin="4,4"
DoubleTapped="OnTaskItemDoubleTapped"
PointerPressed="OnTaskItemPointerPressed">
<Grid.ContextFlyout>
@@ -48,8 +50,32 @@
</MenuFlyout>
</Grid.ContextFlyout>
<!-- Expand/collapse chevron -->
<Button Grid.Column="0"
Command="{Binding ToggleExpandedCommand}"
IsVisible="{Binding HasSubtasks}"
Background="Transparent"
BorderThickness="0"
Padding="0"
Width="16" Height="16"
VerticalAlignment="Center"
Cursor="Hand">
<Panel>
<Canvas Width="10" Height="10"
IsVisible="{Binding !IsExpanded}">
<Path Stroke="{StaticResource TextDimBrush}" StrokeThickness="1.5"
Data="M 2,0 L 8,5 L 2,10"/>
</Canvas>
<Canvas Width="10" Height="10"
IsVisible="{Binding IsExpanded}">
<Path Stroke="{StaticResource TextDimBrush}" StrokeThickness="1.5"
Data="M 0,2 L 5,8 L 10,2"/>
</Canvas>
</Panel>
</Button>
<!-- Circular checkbox -->
<Border Grid.Column="0" Width="22" Height="22"
<Border Grid.Column="1" Width="22" Height="22"
CornerRadius="11"
BorderThickness="2"
BorderBrush="{Binding StatusText, Converter={x:Static conv:CheckboxBorderConverter.Instance}}"
@@ -58,19 +84,16 @@
Cursor="Hand"
PointerPressed="OnCheckboxPressed">
<Panel>
<!-- Checkmark for done -->
<Canvas Width="12" Height="12"
IsVisible="{Binding IsDone}"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Path Stroke="{StaticResource StatusGreenBrush}" StrokeThickness="2"
Data="M 1,6 L 4.5,9.5 L 11,3"/>
</Canvas>
<!-- Running dot -->
<Ellipse Width="8" Height="8"
Fill="{StaticResource StatusOrangeBrush}"
IsVisible="{Binding IsRunning}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Starting dot -->
<Ellipse Width="8" Height="8" Fill="#FFD700"
IsVisible="{Binding IsStarting}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
@@ -78,7 +101,7 @@
</Border>
<!-- Task content -->
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<StackPanel Grid.Column="2" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}" FontWeight="Medium"
Foreground="{Binding TitleForeground}"
TextDecorations="{Binding TitleDecorations}"
@@ -98,6 +121,39 @@
IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNullOrEmpty}}"/>
</StackPanel>
</Grid>
<!-- Row 1: Subtask list (visible when expanded) -->
<ItemsControl Grid.Row="1"
ItemsSource="{Binding Subtasks}"
IsVisible="{Binding IsExpanded}"
Margin="40,0,0,4">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
<Grid ColumnDefinitions="Auto,*" Margin="0,2"
PointerPressed="OnSubtaskPointerPressed">
<Grid.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Edit Task"
Command="{Binding #Root.((vm:TaskListViewModel)DataContext).EditTaskCommand}"/>
</MenuFlyout>
</Grid.ContextFlyout>
<CheckBox Grid.Column="0"
IsChecked="{Binding Completed, Mode=OneWay}"
VerticalAlignment="Center"
Margin="0,0,6,0"
MinWidth="0"
Click="OnSubtaskCheckboxClick"/>
<TextBlock Grid.Column="1"
Text="{Binding Title}"
FontSize="12"
Foreground="{StaticResource TextDimBrush}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>

View File

@@ -1,3 +1,4 @@
using System.Collections.ObjectModel;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
@@ -97,6 +98,29 @@ public partial class TaskListView : UserControl
}
}
private void OnSubtaskPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(null).Properties.IsRightButtonPressed
&& sender is Control { DataContext: SubtaskItemViewModel subtask }
&& DataContext is TaskListViewModel vm)
{
var parent = vm.Tasks.FirstOrDefault(t => t.Subtasks.Contains(subtask));
if (parent is not null)
vm.SelectedTask = parent;
}
}
private async void OnSubtaskCheckboxClick(object? sender, RoutedEventArgs e)
{
if (sender is CheckBox { DataContext: SubtaskItemViewModel subtask }
&& DataContext is TaskListViewModel vm)
{
var parent = vm.Tasks.FirstOrDefault(t => t.Subtasks.Contains(subtask));
if (parent is not null)
await parent.ToggleSubtaskDoneCommand.ExecuteAsync(subtask.Id);
}
}
public void FocusInlineAdd()
{
this.FindControl<TextBox>("InlineAddBox")?.Focus();

View File

@@ -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<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={cfg.DbPath}"));
builder.Services.AddSingleton(cfg);
builder.Services.AddSingleton(dbFactory);
builder.Services.AddSingleton<TagRepository>();
builder.Services.AddSingleton<ListRepository>();
builder.Services.AddSingleton<TaskRepository>();
builder.Services.AddSingleton<SubtaskRepository>();
builder.Services.AddSingleton<WorktreeRepository>();
builder.Services.AddSingleton<TaskRunRepository>();
builder.Services.AddHostedService<StaleTaskRecovery>();
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<ClaudeDoDbContext>());
}
app.MapHub<WorkerHub>("/hub");
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",

View File

@@ -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<ClaudeDoDbContext> _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<ClaudeDoDbContext> dbFactory,
HubBroadcaster broadcaster,
WorktreeManager wtManager,
ClaudeArgsBuilder argsBuilder,
@@ -33,11 +27,7 @@ public sealed class TaskRunner
ILogger<TaskRunner> logger)
{
_claude = claude;
_taskRepo = taskRepo;
_runRepo = runRepo;
_listRepo = listRepo;
_wtRepo = wtRepo;
_subtaskRepo = subtaskRepo;
_dbFactory = dbFactory;
_broadcaster = broadcaster;
_wtManager = wtManager;
_argsBuilder = argsBuilder;
@@ -49,12 +39,24 @@ public sealed class TaskRunner
{
try
{
var list = await _listRepo.GetByIdAsync(task.ListId, ct);
ListEntity? list;
ListConfigEntity? listConfig;
List<SubtaskEntity> subtasks;
using (var context = _dbFactory.CreateDbContext())
{
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.
WorktreeContext? wtCtx = null;
@@ -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)
TaskEntity task;
TaskRunEntity lastRun;
ListEntity list;
ListConfigEntity? listConfig;
WorktreeEntity? worktree;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
task = await taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
var lastRun = await _runRepo.GetLatestByTaskIdAsync(taskId, ct)
var runRepo = new TaskRunRepository(context);
lastRun = await runRepo.GetLatestByTaskIdAsync(taskId, ct)
?? throw new InvalidOperationException("No previous run to continue.");
if (lastRun.SessionId is null)
throw new InvalidOperationException("Previous run has no session ID — cannot resume.");
var list = await _listRepo.GetByIdAsync(task.ListId, ct)
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);
}

View File

@@ -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<ClaudeDoDbContext> _dbFactory;
private readonly WorkerConfig _cfg;
private readonly ILogger<WorktreeManager> _logger;
public WorktreeManager(GitService git, WorktreeRepository wtRepo, WorkerConfig cfg, ILogger<WorktreeManager> logger)
public WorktreeManager(GitService git, IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerConfig cfg, ILogger<WorktreeManager> 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;

View File

@@ -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<ClaudeDoDbContext> _dbFactory;
private readonly TaskRunner _runner;
private readonly WorkerConfig _cfg;
private readonly ILogger<QueueService> _logger;
@@ -26,12 +28,12 @@ public sealed class QueueService : BackgroundService
private readonly SemaphoreSlim _wakeSignal = new(0, 1);
public QueueService(
TaskRepository taskRepo,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
TaskRunner runner,
WorkerConfig cfg,
ILogger<QueueService> 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<string> 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)

View File

@@ -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<ClaudeDoDbContext> _dbFactory;
private readonly ILogger<StaleTaskRecovery> _logger;
public StaleTaskRecovery(TaskRepository tasks, ILogger<StaleTaskRecovery> logger)
public StaleTaskRecovery(IDbContextFactory<ClaudeDoDbContext> dbFactory, ILogger<StaleTaskRecovery> 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

View File

@@ -11,7 +11,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />

View File

@@ -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<ClaudeDoDbContext>()
.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<ClaudeDoDbContext>
{
private readonly DbFixture _fixture;
public TestDbContextFactory(DbFixture fixture) => _fixture = fixture;
public ClaudeDoDbContext CreateDbContext() => _fixture.CreateContext();
}

View File

@@ -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();
}
}

View File

@@ -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()

View File

@@ -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<string> 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);

View File

@@ -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()
{

View File

@@ -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<WorktreeManager>.Instance);
return (mgr, wtRepo);
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.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);
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<WorktreeManager>.Instance);
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => 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);
}

View File

@@ -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<WorktreeManager>.Instance);
var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.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<TaskRunner>.Instance);
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance);
return (service, fake);
}

View File

@@ -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<StaleTaskRecovery>.Instance);
var recovery = new StaleTaskRecovery(_db.CreateFactory(), NullLogger<StaleTaskRecovery>.Instance);
await recovery.StartAsync(CancellationToken.None);
var r = await _tasks.GetByIdAsync(running.Id);