Compare commits
10 Commits
9236ca6d45
...
4ca48044db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ca48044db | ||
|
|
611454df1e | ||
|
|
8d61b05179 | ||
|
|
7d0ca45a60 | ||
|
|
36484ed45a | ||
|
|
b7be52a623 | ||
|
|
34ca1b018f | ||
|
|
51a5dcbb73 | ||
|
|
f8f13865d2 | ||
|
|
a064865417 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -61,3 +61,4 @@ Desktop.ini
|
|||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
|
design-time.db
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- .NET 8.0, Avalonia 12.0.0 (Fluent theme)
|
- .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
|
- SignalR for real-time IPC
|
||||||
- CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`)
|
- CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`)
|
||||||
- Git worktrees for task isolation
|
- 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`
|
- Worker config: `~/.todo-app/worker.config.json`
|
||||||
- Logs: `~/.todo-app/logs/`
|
- Logs: `~/.todo-app/logs/`
|
||||||
- Worktrees: configured per worker (sibling or central strategy)
|
- Worktrees: configured per worker (sibling or central strategy)
|
||||||
- Schema: `schema/schema.sql` (embedded resource in ClaudeDo.Data)
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Repository pattern — each entity has its own async repository
|
- Repository pattern — each entity has its own async repository
|
||||||
- All data operations are async with CancellationToken support
|
- 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
|
- Task status flow: Manual | Queued -> Running -> Done | Failed
|
||||||
- Worktree state flow: Active -> Merged | Discarded | Kept
|
- Worktree state flow: Active -> Merged | Discarded | Kept
|
||||||
- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup
|
- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup
|
||||||
|
|||||||
@@ -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');
|
|
||||||
@@ -5,6 +5,7 @@ using ClaudeDo.Data.Repositories;
|
|||||||
using ClaudeDo.Ui;
|
using ClaudeDo.Ui;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
@@ -18,9 +19,11 @@ sealed class Program
|
|||||||
var services = BuildServices();
|
var services = BuildServices();
|
||||||
App.Services = services;
|
App.Services = services;
|
||||||
|
|
||||||
// Ensure DB schema exists
|
using (var scope = services.CreateScope())
|
||||||
var factory = services.GetRequiredService<SqliteConnectionFactory>();
|
{
|
||||||
SchemaInitializer.Apply(factory);
|
ClaudeDoDbContext.MigrateAndConfigure(
|
||||||
|
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -55,14 +58,10 @@ sealed class Program
|
|||||||
|
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
sc.AddSingleton(settings);
|
sc.AddSingleton(settings);
|
||||||
sc.AddSingleton(new SqliteConnectionFactory(dbPath));
|
sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
||||||
|
opt.UseSqlite($"Data Source={dbPath}"));
|
||||||
// Repositories
|
sc.AddScoped<ClaudeDoDbContext>(sp =>
|
||||||
sc.AddSingleton<ListRepository>();
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||||
sc.AddSingleton<TaskRepository>();
|
|
||||||
sc.AddSingleton<SubtaskRepository>();
|
|
||||||
sc.AddSingleton<TagRepository>();
|
|
||||||
sc.AddSingleton<WorktreeRepository>();
|
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
sc.AddSingleton<GitService>();
|
sc.AddSingleton<GitService>();
|
||||||
@@ -72,30 +71,21 @@ sealed class Program
|
|||||||
sc.AddTransient<ListEditorViewModel>();
|
sc.AddTransient<ListEditorViewModel>();
|
||||||
sc.AddTransient<TaskEditorViewModel>();
|
sc.AddTransient<TaskEditorViewModel>();
|
||||||
sc.AddSingleton<StatusBarViewModel>();
|
sc.AddSingleton<StatusBarViewModel>();
|
||||||
sc.AddSingleton<TaskDetailViewModel>(sp => new TaskDetailViewModel(
|
sc.AddSingleton<TaskDetailViewModel>();
|
||||||
sp.GetRequiredService<TaskRepository>(),
|
|
||||||
sp.GetRequiredService<WorktreeRepository>(),
|
|
||||||
sp.GetRequiredService<ListRepository>(),
|
|
||||||
sp.GetRequiredService<GitService>(),
|
|
||||||
sp.GetRequiredService<WorkerClient>(),
|
|
||||||
sp.GetRequiredService<TagRepository>(),
|
|
||||||
sp.GetRequiredService<SubtaskRepository>()));
|
|
||||||
sc.AddSingleton<TaskListViewModel>(sp =>
|
sc.AddSingleton<TaskListViewModel>(sp =>
|
||||||
{
|
{
|
||||||
var taskRepo = sp.GetRequiredService<TaskRepository>();
|
var dbFactory = sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>();
|
||||||
var tagRepo = sp.GetRequiredService<TagRepository>();
|
|
||||||
var listRepo = sp.GetRequiredService<ListRepository>();
|
|
||||||
var worker = sp.GetRequiredService<WorkerClient>();
|
var worker = sp.GetRequiredService<WorkerClient>();
|
||||||
var statusBar = sp.GetRequiredService<StatusBarViewModel>();
|
var statusBar = sp.GetRequiredService<StatusBarViewModel>();
|
||||||
return new TaskListViewModel(
|
return new TaskListViewModel(
|
||||||
taskRepo, tagRepo, listRepo, worker,
|
dbFactory, worker,
|
||||||
() => sp.GetRequiredService<TaskEditorViewModel>(),
|
() => sp.GetRequiredService<TaskEditorViewModel>(),
|
||||||
msg => statusBar.ShowMessage(msg));
|
msg => statusBar.ShowMessage(msg));
|
||||||
});
|
});
|
||||||
sc.AddSingleton<MainWindowViewModel>(sp =>
|
sc.AddSingleton<MainWindowViewModel>(sp =>
|
||||||
{
|
{
|
||||||
return new MainWindowViewModel(
|
return new MainWindowViewModel(
|
||||||
sp.GetRequiredService<ListRepository>(),
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
sp.GetRequiredService<WorkerClient>(),
|
sp.GetRequiredService<WorkerClient>(),
|
||||||
sp.GetRequiredService<TaskListViewModel>(),
|
sp.GetRequiredService<TaskListViewModel>(),
|
||||||
sp.GetRequiredService<TaskDetailViewModel>(),
|
sp.GetRequiredService<TaskDetailViewModel>(),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
|||||||
|
|
||||||
## Repositories
|
## 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`
|
- **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync`
|
||||||
- **ListRepository** — CRUD, tag junction management
|
- **ListRepository** — CRUD, tag junction management
|
||||||
@@ -20,8 +20,8 @@ All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each met
|
|||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
- **SqliteConnectionFactory** — creates connections, applies WAL mode once, enforces foreign keys via PRAGMA
|
- **ClaudeDoDbContext** — EF Core DbContext; configured with WAL mode and foreign keys via `UseSqlite` options
|
||||||
- **SchemaInitializer** — applies embedded `schema/schema.sql` idempotently (IF NOT EXISTS, INSERT OR IGNORE)
|
- **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`
|
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
|
||||||
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
|
- **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
|
## 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
|
## 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)
|
- Primary keys are `init`-only strings (GUIDs assigned at creation)
|
||||||
- Nullable fields use `DBNull.Value` checks
|
|
||||||
- All methods are async with CancellationToken where applicable
|
- All methods are async with CancellationToken where applicable
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||||
</ItemGroup>
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<ItemGroup>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<EmbeddedResource Include="..\..\schema\schema.sql" Link="schema.sql" LogicalName="ClaudeDo.Data.schema.sql" />
|
</PackageReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
64
src/ClaudeDo.Data/ClaudeDoDbContext.cs
Normal file
64
src/ClaudeDo.Data/ClaudeDoDbContext.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/ClaudeDo.Data/ClaudeDoDbContextFactory.cs
Normal file
15
src/ClaudeDo.Data/ClaudeDoDbContextFactory.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs
Normal file
36
src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs
Normal 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs
Normal file
22
src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs
Normal 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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs
Normal file
75
src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
298
src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
Normal file
298
src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
479
src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs
Normal file
479
src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,7 @@ public sealed class ListConfigEntity
|
|||||||
public string? Model { get; set; }
|
public string? Model { get; set; }
|
||||||
public string? SystemPrompt { get; set; }
|
public string? SystemPrompt { get; set; }
|
||||||
public string? AgentPath { get; set; }
|
public string? AgentPath { get; set; }
|
||||||
|
|
||||||
|
// Navigation property
|
||||||
|
public ListEntity List { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ public sealed class ListEntity
|
|||||||
public required DateTime CreatedAt { get; init; }
|
public required DateTime CreatedAt { get; init; }
|
||||||
public string? WorkingDir { get; set; }
|
public string? WorkingDir { get; set; }
|
||||||
public string DefaultCommitType { get; set; } = "chore";
|
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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,7 @@ public sealed class SubtaskEntity
|
|||||||
public bool Completed { get; set; }
|
public bool Completed { get; set; }
|
||||||
public int OrderNum { get; set; }
|
public int OrderNum { get; set; }
|
||||||
public required DateTime CreatedAt { get; init; }
|
public required DateTime CreatedAt { get; init; }
|
||||||
|
|
||||||
|
// Navigation property
|
||||||
|
public TaskEntity Task { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,8 @@ public sealed class TagEntity
|
|||||||
{
|
{
|
||||||
public long Id { get; init; }
|
public long Id { get; init; }
|
||||||
public required string Name { get; set; }
|
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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ public sealed class TaskEntity
|
|||||||
public string? Model { get; set; }
|
public string? Model { get; set; }
|
||||||
public string? SystemPrompt { get; set; }
|
public string? SystemPrompt { get; set; }
|
||||||
public string? AgentPath { 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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ public sealed class TaskRunEntity
|
|||||||
public string? LogPath { get; set; }
|
public string? LogPath { get; set; }
|
||||||
public DateTime? StartedAt { get; set; }
|
public DateTime? StartedAt { get; set; }
|
||||||
public DateTime? FinishedAt { get; set; }
|
public DateTime? FinishedAt { get; set; }
|
||||||
|
|
||||||
|
// Navigation property
|
||||||
|
public TaskEntity Task { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ public sealed class WorktreeEntity
|
|||||||
public string? DiffStat { get; set; }
|
public string? DiffStat { get; set; }
|
||||||
public WorktreeState State { get; set; } = WorktreeState.Active;
|
public WorktreeState State { get; set; } = WorktreeState.Active;
|
||||||
public required DateTime CreatedAt { get; init; }
|
public required DateTime CreatedAt { get; init; }
|
||||||
|
|
||||||
|
// Navigation property
|
||||||
|
public TaskEntity Task { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,157 +1,89 @@
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Repositories;
|
namespace ClaudeDo.Data.Repositories;
|
||||||
|
|
||||||
public sealed class ListRepository
|
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)
|
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
_context.Lists.Add(entity);
|
||||||
await using var cmd = conn.CreateCommand();
|
await _context.SaveChangesAsync(ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
|
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
_context.Lists.Update(entity);
|
||||||
await using var cmd = conn.CreateCommand();
|
await _context.SaveChangesAsync(ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(string listId, CancellationToken ct = default)
|
public async Task DeleteAsync(string listId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct);
|
||||||
await using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = "DELETE FROM lists WHERE id = @id";
|
|
||||||
cmd.Parameters.AddWithValue("@id", listId);
|
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)
|
public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
return await _context.Lists.FirstOrDefaultAsync(l => l.Id == listId, ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default)
|
public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default)
|
public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
return await _context.Lists
|
||||||
await using var cmd = conn.CreateCommand();
|
.Where(l => l.Id == listId)
|
||||||
cmd.CommandText = """
|
.SelectMany(l => l.Tags)
|
||||||
SELECT t.id, t.name FROM tags t
|
.ToListAsync(ct);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
|
public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct);
|
||||||
await using var cmd = conn.CreateCommand();
|
var tag = await _context.Tags.FindAsync([tagId], ct);
|
||||||
cmd.CommandText = "INSERT OR IGNORE INTO list_tags (list_id, tag_id) VALUES (@list_id, @tag_id)";
|
if (tag is not null && !list.Tags.Any(t => t.Id == tagId))
|
||||||
cmd.Parameters.AddWithValue("@list_id", listId);
|
{
|
||||||
cmd.Parameters.AddWithValue("@tag_id", tagId);
|
list.Tags.Add(tag);
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
|
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct);
|
||||||
await using var cmd = conn.CreateCommand();
|
var tag = list.Tags.FirstOrDefault(t => t.Id == tagId);
|
||||||
cmd.CommandText = "DELETE FROM list_tags WHERE list_id = @list_id AND tag_id = @tag_id";
|
if (tag is not null)
|
||||||
cmd.Parameters.AddWithValue("@list_id", listId);
|
{
|
||||||
cmd.Parameters.AddWithValue("@tag_id", tagId);
|
list.Tags.Remove(tag);
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
|
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SetConfigAsync(ListConfigEntity entity, CancellationToken ct = default)
|
public async Task SetConfigAsync(ListConfigEntity config, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
var existing = await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == config.ListId, ct);
|
||||||
await using var cmd = conn.CreateCommand();
|
if (existing is null)
|
||||||
cmd.CommandText = """
|
|
||||||
INSERT OR REPLACE INTO list_config (list_id, model, system_prompt, agent_path)
|
|
||||||
VALUES (@list_id, @model, @system_prompt, @agent_path)
|
|
||||||
""";
|
|
||||||
cmd.Parameters.AddWithValue("@list_id", entity.ListId);
|
|
||||||
cmd.Parameters.AddWithValue("@model", (object?)entity.Model ?? DBNull.Value);
|
|
||||||
cmd.Parameters.AddWithValue("@system_prompt", (object?)entity.SystemPrompt ?? DBNull.Value);
|
|
||||||
cmd.Parameters.AddWithValue("@agent_path", (object?)entity.AgentPath ?? DBNull.Value);
|
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ListEntity ReadList(SqliteDataReader reader) => new()
|
|
||||||
{
|
{
|
||||||
Id = reader.GetString(0),
|
_context.ListConfigs.Add(config);
|
||||||
Name = reader.GetString(1),
|
}
|
||||||
CreatedAt = DateTime.Parse(reader.GetString(2)),
|
else
|
||||||
WorkingDir = reader.IsDBNull(3) ? null : reader.GetString(3),
|
{
|
||||||
DefaultCommitType = reader.GetString(4),
|
existing.Model = config.Model;
|
||||||
};
|
existing.SystemPrompt = config.SystemPrompt;
|
||||||
|
existing.AgentPath = config.AgentPath;
|
||||||
|
}
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,41 @@
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Repositories;
|
namespace ClaudeDo.Data.Repositories;
|
||||||
|
|
||||||
public sealed class SubtaskRepository
|
public sealed class SubtaskRepository
|
||||||
{
|
{
|
||||||
private readonly SqliteConnectionFactory _factory;
|
private readonly ClaudeDoDbContext _context;
|
||||||
|
|
||||||
public SubtaskRepository(SqliteConnectionFactory factory) => _factory = factory;
|
public SubtaskRepository(ClaudeDoDbContext context) => _context = context;
|
||||||
|
|
||||||
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 async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default)
|
public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
_context.Subtasks.Add(entity);
|
||||||
await using var cmd = conn.CreateCommand();
|
await _context.SaveChangesAsync(ct);
|
||||||
cmd.CommandText = """
|
}
|
||||||
INSERT INTO subtasks (id, task_id, title, completed, order_num, created_at)
|
|
||||||
VALUES (@id, @task_id, @title, @completed, @order_num, @created_at)
|
public async Task<List<SubtaskEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||||
""";
|
{
|
||||||
BindSubtask(cmd, entity);
|
return await _context.Subtasks
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
.Where(s => s.TaskId == taskId)
|
||||||
|
.OrderBy(s => s.OrderNum)
|
||||||
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default)
|
public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
_context.Subtasks.Update(entity);
|
||||||
await using var cmd = conn.CreateCommand();
|
await _context.SaveChangesAsync(ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 _context.Subtasks.Where(s => s.Id == subtaskId).ExecuteDeleteAsync(ct);
|
||||||
await using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = "DELETE FROM subtasks WHERE id = @id";
|
|
||||||
cmd.Parameters.AddWithValue("@id", id);
|
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void BindSubtask(SqliteCommand cmd, SubtaskEntity e)
|
public async Task DeleteByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
cmd.Parameters.AddWithValue("@id", e.Id);
|
await _context.Subtasks.Where(s => s.TaskId == taskId).ExecuteDeleteAsync(ct);
|
||||||
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"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,28 @@
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Repositories;
|
namespace ClaudeDo.Data.Repositories;
|
||||||
|
|
||||||
public sealed class TagRepository
|
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)
|
public async Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<long> GetOrCreateAsync(string name, CancellationToken ct = default)
|
public async Task<long> GetOrCreateAsync(string name, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
|
||||||
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);
|
|
||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
return (long)existing;
|
return existing.Id;
|
||||||
|
|
||||||
await using var ins = conn.CreateCommand();
|
var tag = new TagEntity { Name = name };
|
||||||
ins.CommandText = "INSERT INTO tags (name) VALUES (@name) RETURNING id";
|
_context.Tags.Add(tag);
|
||||||
ins.Parameters.AddWithValue("@name", name);
|
await _context.SaveChangesAsync(ct);
|
||||||
|
return tag.Id;
|
||||||
return (long)(await ins.ExecuteScalarAsync(ct))!;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,171 +1,146 @@
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Repositories;
|
namespace ClaudeDo.Data.Repositories;
|
||||||
|
|
||||||
public sealed class TaskRepository
|
public sealed class TaskRepository
|
||||||
{
|
{
|
||||||
private readonly SqliteConnectionFactory _factory;
|
private readonly ClaudeDoDbContext _context;
|
||||||
|
|
||||||
public TaskRepository(SqliteConnectionFactory factory) => _factory = factory;
|
public TaskRepository(ClaudeDoDbContext context) => _context = context;
|
||||||
|
|
||||||
#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
|
|
||||||
|
|
||||||
#region CRUD
|
#region CRUD
|
||||||
|
|
||||||
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
|
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
_context.Tasks.Add(entity);
|
||||||
await using var cmd = conn.CreateCommand();
|
await _context.SaveChangesAsync(ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
|
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
_context.Tasks.Update(entity);
|
||||||
await using var cmd = conn.CreateCommand();
|
await _context.SaveChangesAsync(ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
|
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct);
|
||||||
await using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = "DELETE FROM tasks WHERE id = @id";
|
|
||||||
cmd.Parameters.AddWithValue("@id", taskId);
|
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
|
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
return await _context.Tasks
|
||||||
await using var cmd = conn.CreateCommand();
|
.Where(t => t.ListId == listId)
|
||||||
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";
|
.OrderBy(t => t.CreatedAt)
|
||||||
cmd.Parameters.AddWithValue("@list_id", listId);
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
// Kept for backwards-compatibility with callers using the old name.
|
||||||
var result = new List<TaskEntity>();
|
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
|
||||||
while (await reader.ReadAsync(ct))
|
=> GetByListIdAsync(listId, ct);
|
||||||
result.Add(ReadTask(reader));
|
|
||||||
return result;
|
#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
|
#endregion
|
||||||
|
|
||||||
#region Tag junction
|
#region Tags
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
|
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct);
|
||||||
await using var cmd = conn.CreateCommand();
|
var tag = await _context.Tags.FindAsync([tagId], ct);
|
||||||
cmd.CommandText = "INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (@task_id, @tag_id)";
|
if (tag is not null && !task.Tags.Any(t => t.Id == tagId))
|
||||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
{
|
||||||
cmd.Parameters.AddWithValue("@tag_id", tagId);
|
task.Tags.Add(tag);
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
|
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct);
|
||||||
await using var cmd = conn.CreateCommand();
|
var tag = task.Tags.FirstOrDefault(t => t.Id == tagId);
|
||||||
cmd.CommandText = "DELETE FROM task_tags WHERE task_id = @task_id AND tag_id = @tag_id";
|
if (tag is not null)
|
||||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
{
|
||||||
cmd.Parameters.AddWithValue("@tag_id", tagId);
|
task.Tags.Remove(tag);
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
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)
|
public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
var taskTags = _context.Tasks
|
||||||
await using var cmd = conn.CreateCommand();
|
.Where(t => t.Id == taskId)
|
||||||
cmd.CommandText = """
|
.SelectMany(t => t.Tags);
|
||||||
SELECT DISTINCT t.id, t.name FROM tags t
|
var listTags = _context.Tasks
|
||||||
WHERE t.id IN (
|
.Where(t => t.Id == taskId)
|
||||||
SELECT tag_id FROM task_tags WHERE task_id = @task_id
|
.SelectMany(t => t.List.Tags);
|
||||||
UNION
|
return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -174,146 +149,38 @@ public sealed class TaskRepository
|
|||||||
|
|
||||||
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// Atomically claim the next queued agent task: the UPDATE flips its
|
// Atomic queue claim: UPDATE + RETURNING in one statement prevents TOCTOU races.
|
||||||
// status to 'running' in the same statement that returns its row,
|
// Uses raw SQL because EF cannot express UPDATE...RETURNING.
|
||||||
// eliminating the TOCTOU gap where two queue-loop iterations could
|
// Includes both task-level and list-level "agent" tag so lists tagged "agent"
|
||||||
// both select the same queued task before either marked it running.
|
// automatically enqueue all their tasks without per-task tagging.
|
||||||
// The caller is responsible for populating started_at shortly after.
|
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — use the same format for comparison.
|
||||||
await using var conn = _factory.Open();
|
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||||
await using var cmd = conn.CreateCommand();
|
var result = await _context.Tasks.FromSqlRaw("""
|
||||||
cmd.CommandText = """
|
UPDATE tasks SET status = 'running'
|
||||||
UPDATE tasks
|
|
||||||
SET status = 'running'
|
|
||||||
WHERE id = (
|
WHERE id = (
|
||||||
SELECT t.id
|
SELECT t.id FROM tasks t
|
||||||
FROM tasks t
|
|
||||||
WHERE t.status = 'queued'
|
WHERE t.status = 'queued'
|
||||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now)
|
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
|
||||||
AND EXISTS (
|
AND (
|
||||||
|
EXISTS (
|
||||||
SELECT 1 FROM task_tags tt
|
SELECT 1 FROM task_tags tt
|
||||||
JOIN tags tg ON tg.id = tt.tag_id
|
JOIN tags tg ON tg.id = tt.tag_id
|
||||||
WHERE tt.task_id = t.id AND tg.name = 'agent'
|
WHERE tt.task_id = t.id AND tg.name = 'agent'
|
||||||
UNION
|
)
|
||||||
|
OR EXISTS (
|
||||||
SELECT 1 FROM list_tags lt
|
SELECT 1 FROM list_tags lt
|
||||||
JOIN tags tg ON tg.id = lt.tag_id
|
JOIN tags tg ON tg.id = lt.tag_id
|
||||||
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
|
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
|
||||||
)
|
)
|
||||||
|
)
|
||||||
ORDER BY t.created_at ASC
|
ORDER BY t.created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
RETURNING id, list_id, title, description, status, scheduled_for,
|
RETURNING *
|
||||||
result, log_path, created_at, started_at, finished_at, commit_type,
|
""", nowStr).ToListAsync(ct);
|
||||||
model, system_prompt, agent_path
|
|
||||||
""";
|
|
||||||
cmd.Parameters.AddWithValue("@now", now.ToString("o"));
|
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
return result.FirstOrDefault();
|
||||||
if (!await reader.ReadAsync(ct)) return null;
|
|
||||||
return ReadTask(reader);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,139 +1,44 @@
|
|||||||
using System.Globalization;
|
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Repositories;
|
namespace ClaudeDo.Data.Repositories;
|
||||||
|
|
||||||
public sealed class TaskRunRepository
|
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)
|
public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
_context.TaskRuns.Add(entity);
|
||||||
await using var cmd = conn.CreateCommand();
|
await _context.SaveChangesAsync(ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default)
|
public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
_context.TaskRuns.Update(entity);
|
||||||
await using var cmd = conn.CreateCommand();
|
await _context.SaveChangesAsync(ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
return await _context.TaskRuns.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<TaskRunEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
|
public async Task<List<TaskRunEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
return await _context.TaskRuns
|
||||||
await using var cmd = conn.CreateCommand();
|
.Where(r => r.TaskId == taskId)
|
||||||
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";
|
.OrderBy(r => r.RunNumber)
|
||||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
|
||||||
var result = new List<TaskRunEntity>();
|
|
||||||
while (await reader.ReadAsync(ct))
|
|
||||||
result.Add(ReadRun(reader));
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TaskRunEntity?> GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default)
|
public async Task<TaskRunEntity?> GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
return await _context.TaskRuns
|
||||||
await using var cmd = conn.CreateCommand();
|
.Where(r => r.TaskId == taskId)
|
||||||
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";
|
.OrderByDescending(r => r.RunNumber)
|
||||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
|
||||||
if (!await reader.ReadAsync(ct)) return null;
|
|
||||||
return ReadRun(reader);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +1,43 @@
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Repositories;
|
namespace ClaudeDo.Data.Repositories;
|
||||||
|
|
||||||
public sealed class WorktreeRepository
|
public sealed class WorktreeRepository
|
||||||
{
|
{
|
||||||
private readonly SqliteConnectionFactory _factory;
|
private readonly ClaudeDoDbContext _context;
|
||||||
|
|
||||||
public WorktreeRepository(SqliteConnectionFactory factory) => _factory = factory;
|
public WorktreeRepository(ClaudeDoDbContext context) => _context = context;
|
||||||
|
|
||||||
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 async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default)
|
public async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
_context.Worktrees.Add(entity);
|
||||||
await using var cmd = conn.CreateCommand();
|
await _context.SaveChangesAsync(ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorktreeEntity?> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
|
public async Task<WorktreeEntity?> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
return await _context.Worktrees.FirstOrDefaultAsync(w => w.TaskId == taskId, ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateHeadAsync(string taskId, string headCommit, string? diffStat, CancellationToken ct = default)
|
public async Task UpdateHeadAsync(string taskId, string headCommit, string? diffStat, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
await _context.Worktrees
|
||||||
await using var cmd = conn.CreateCommand();
|
.Where(w => w.TaskId == taskId)
|
||||||
cmd.CommandText = "UPDATE worktrees SET head_commit = @head_commit, diff_stat = @diff_stat WHERE task_id = @task_id";
|
.ExecuteUpdateAsync(s => s
|
||||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
.SetProperty(w => w.HeadCommit, headCommit)
|
||||||
cmd.Parameters.AddWithValue("@head_commit", headCommit);
|
.SetProperty(w => w.DiffStat, diffStat), ct);
|
||||||
cmd.Parameters.AddWithValue("@diff_stat", (object?)diffStat ?? DBNull.Value);
|
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SetStateAsync(string taskId, WorktreeState state, CancellationToken ct = default)
|
public async Task SetStateAsync(string taskId, WorktreeState state, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
await _context.Worktrees
|
||||||
await using var cmd = conn.CreateCommand();
|
.Where(w => w.TaskId == taskId)
|
||||||
cmd.CommandText = "UPDATE worktrees SET state = @state WHERE task_id = @task_id";
|
.ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), ct);
|
||||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
|
||||||
cmd.Parameters.AddWithValue("@state", ToDb(state));
|
|
||||||
await cmd.ExecuteNonQueryAsync(ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
|
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var conn = _factory.Open();
|
await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Steps;
|
namespace ClaudeDo.Installer.Steps;
|
||||||
|
|
||||||
@@ -14,8 +15,11 @@ public sealed class InitDatabaseStep : IInstallStep
|
|||||||
var expandedPath = Paths.Expand(ctx.DbPath);
|
var expandedPath = Paths.Expand(ctx.DbPath);
|
||||||
progress.Report($"Initializing database at {expandedPath}");
|
progress.Report($"Initializing database at {expandedPath}");
|
||||||
|
|
||||||
var factory = new SqliteConnectionFactory(expandedPath);
|
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
SchemaInitializer.Apply(factory);
|
.UseSqlite($"Data Source={expandedPath}")
|
||||||
|
.Options;
|
||||||
|
using var context = new ClaudeDoDbContext(options);
|
||||||
|
ClaudeDoDbContext.MigrateAndConfigure(context);
|
||||||
|
|
||||||
progress.Report("Schema applied successfully");
|
progress.Report("Schema applied successfully");
|
||||||
return Task.FromResult(StepResult.Ok());
|
return Task.FromResult(StepResult.Ok());
|
||||||
|
|||||||
@@ -2,18 +2,20 @@ using System.Collections.ObjectModel;
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.Views;
|
using ClaudeDo.Ui.Views;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
namespace ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
public partial class MainWindowViewModel : ViewModelBase
|
public partial class MainWindowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly ListRepository _listRepo;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly WorkerClient _worker;
|
private readonly WorkerClient _worker;
|
||||||
private readonly Func<ListEditorViewModel> _listEditorFactory;
|
private readonly Func<ListEditorViewModel> _listEditorFactory;
|
||||||
|
|
||||||
@@ -26,14 +28,14 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
public StatusBarViewModel StatusBar { get; }
|
public StatusBarViewModel StatusBar { get; }
|
||||||
|
|
||||||
public MainWindowViewModel(
|
public MainWindowViewModel(
|
||||||
ListRepository listRepo,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
WorkerClient worker,
|
WorkerClient worker,
|
||||||
TaskListViewModel taskList,
|
TaskListViewModel taskList,
|
||||||
TaskDetailViewModel taskDetail,
|
TaskDetailViewModel taskDetail,
|
||||||
StatusBarViewModel statusBar,
|
StatusBarViewModel statusBar,
|
||||||
Func<ListEditorViewModel> listEditorFactory)
|
Func<ListEditorViewModel> listEditorFactory)
|
||||||
{
|
{
|
||||||
_listRepo = listRepo;
|
_dbFactory = dbFactory;
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_listEditorFactory = listEditorFactory;
|
_listEditorFactory = listEditorFactory;
|
||||||
TaskList = taskList;
|
TaskList = taskList;
|
||||||
@@ -48,7 +50,9 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
try
|
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)
|
foreach (var l in lists)
|
||||||
Lists.Add(new ListItemViewModel(l));
|
Lists.Add(new ListItemViewModel(l));
|
||||||
}
|
}
|
||||||
@@ -91,10 +95,12 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
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);
|
var configEntity = editor.BuildConfig(entity.Id);
|
||||||
if (configEntity is not null)
|
if (configEntity is not null)
|
||||||
await _listRepo.SetConfigAsync(configEntity);
|
await listRepo.SetConfigAsync(configEntity);
|
||||||
Lists.Add(new ListItemViewModel(entity));
|
Lists.Add(new ListItemViewModel(entity));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -107,10 +113,17 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
private async Task EditList()
|
private async Task EditList()
|
||||||
{
|
{
|
||||||
if (SelectedList is null) return;
|
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();
|
var editor = _listEditorFactory();
|
||||||
await editor.LoadAgentsAsync(_worker);
|
await editor.LoadAgentsAsync(_worker);
|
||||||
editor.InitForEdit(existing, config);
|
editor.InitForEdit(existing, config);
|
||||||
@@ -125,10 +138,12 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
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);
|
var configEntity = editor.BuildConfig(entity.Id);
|
||||||
if (configEntity is not null)
|
if (configEntity is not null)
|
||||||
await _listRepo.SetConfigAsync(configEntity);
|
await listRepo.SetConfigAsync(configEntity);
|
||||||
SelectedList.Name = entity.Name;
|
SelectedList.Name = entity.Name;
|
||||||
SelectedList.WorkingDir = entity.WorkingDir;
|
SelectedList.WorkingDir = entity.WorkingDir;
|
||||||
SelectedList.DefaultCommitType = entity.DefaultCommitType;
|
SelectedList.DefaultCommitType = entity.DefaultCommitType;
|
||||||
@@ -146,7 +161,9 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
// TODO: confirmation dialog
|
// TODO: confirmation dialog
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _listRepo.DeleteAsync(SelectedList.Id);
|
using var context = _dbFactory.CreateDbContext();
|
||||||
|
var listRepo = new ListRepository(context);
|
||||||
|
await listRepo.DeleteAsync(SelectedList.Id);
|
||||||
Lists.Remove(SelectedList);
|
Lists.Remove(SelectedList);
|
||||||
SelectedList = null;
|
SelectedList = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
@@ -9,18 +10,15 @@ using ClaudeDo.Ui.Helpers;
|
|||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
namespace ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
public partial class TaskDetailViewModel : ViewModelBase
|
public partial class TaskDetailViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly TaskRepository _taskRepo;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly WorktreeRepository _worktreeRepo;
|
|
||||||
private readonly ListRepository _listRepo;
|
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
private readonly WorkerClient _worker;
|
private readonly WorkerClient _worker;
|
||||||
private readonly TagRepository _tagRepo;
|
|
||||||
private readonly SubtaskRepository _subtaskRepo;
|
|
||||||
|
|
||||||
[ObservableProperty] private string _title = "";
|
[ObservableProperty] private string _title = "";
|
||||||
[ObservableProperty] private string? _description;
|
[ObservableProperty] private string? _description;
|
||||||
@@ -62,17 +60,11 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
|
|
||||||
public event Action<string>? TaskChanged;
|
public event Action<string>? TaskChanged;
|
||||||
|
|
||||||
public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo,
|
public TaskDetailViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, GitService git, WorkerClient worker)
|
||||||
ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo,
|
|
||||||
SubtaskRepository subtaskRepo)
|
|
||||||
{
|
{
|
||||||
_taskRepo = taskRepo;
|
_dbFactory = dbFactory;
|
||||||
_worktreeRepo = worktreeRepo;
|
|
||||||
_listRepo = listRepo;
|
|
||||||
_git = git;
|
_git = git;
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_tagRepo = tagRepo;
|
|
||||||
_subtaskRepo = subtaskRepo;
|
|
||||||
|
|
||||||
worker.TaskMessageEvent += OnTaskMessage;
|
worker.TaskMessageEvent += OnTaskMessage;
|
||||||
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
|
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
|
||||||
@@ -98,10 +90,26 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
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;
|
if (task is null) return;
|
||||||
ct.ThrowIfCancellationRequested();
|
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)
|
if (AvailableAgents.Count == 0)
|
||||||
{
|
{
|
||||||
var agents = await _worker.GetAgentsAsync();
|
var agents = await _worker.GetAgentsAsync();
|
||||||
@@ -149,14 +157,12 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
Tags.Clear();
|
Tags.Clear();
|
||||||
var tags = await _taskRepo.GetTagsAsync(taskId, ct);
|
|
||||||
foreach (var tag in tags)
|
foreach (var tag in tags)
|
||||||
Tags.Add(tag);
|
Tags.Add(tag);
|
||||||
|
|
||||||
// Tear down old subtask subscriptions before replacing them.
|
// Tear down old subtask subscriptions before replacing them.
|
||||||
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
|
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
|
||||||
Subtasks.Clear();
|
Subtasks.Clear();
|
||||||
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId, ct);
|
|
||||||
foreach (var s in subtasks)
|
foreach (var s in subtasks)
|
||||||
{
|
{
|
||||||
var vm = SubtaskItemViewModel.From(s);
|
var vm = SubtaskItemViewModel.From(s);
|
||||||
@@ -181,7 +187,9 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
if (_isLoading || _taskId is null) return;
|
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;
|
if (entity is null) return;
|
||||||
|
|
||||||
entity.Title = Title;
|
entity.Title = Title;
|
||||||
@@ -196,7 +204,7 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
|
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
|
||||||
entity.Status = status;
|
entity.Status = status;
|
||||||
|
|
||||||
await _taskRepo.UpdateAsync(entity);
|
await taskRepo.UpdateAsync(entity);
|
||||||
StatusText = entity.Status.ToString().ToLowerInvariant();
|
StatusText = entity.Status.ToString().ToLowerInvariant();
|
||||||
TaskChanged?.Invoke(_taskId);
|
TaskChanged?.Invoke(_taskId);
|
||||||
}
|
}
|
||||||
@@ -207,11 +215,15 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
var name = NewTagInput.Trim();
|
var name = NewTagInput.Trim();
|
||||||
if (string.IsNullOrEmpty(name) || _taskId is null) return;
|
if (string.IsNullOrEmpty(name) || _taskId is null) return;
|
||||||
|
|
||||||
var tagId = await _tagRepo.GetOrCreateAsync(name);
|
using var context = _dbFactory.CreateDbContext();
|
||||||
await _taskRepo.AddTagAsync(_taskId, tagId);
|
var tagRepo = new TagRepository(context);
|
||||||
|
var taskRepo = new TaskRepository(context);
|
||||||
|
|
||||||
|
var tagId = await tagRepo.GetOrCreateAsync(name);
|
||||||
|
await taskRepo.AddTagAsync(_taskId, tagId);
|
||||||
|
|
||||||
Tags.Clear();
|
Tags.Clear();
|
||||||
var tags = await _taskRepo.GetTagsAsync(_taskId);
|
var tags = await taskRepo.GetTagsAsync(_taskId);
|
||||||
foreach (var tag in tags)
|
foreach (var tag in tags)
|
||||||
Tags.Add(tag);
|
Tags.Add(tag);
|
||||||
|
|
||||||
@@ -223,7 +235,9 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
private async Task RemoveTag(TagEntity tag)
|
private async Task RemoveTag(TagEntity tag)
|
||||||
{
|
{
|
||||||
if (_taskId is null) return;
|
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);
|
Tags.Remove(tag);
|
||||||
TaskChanged?.Invoke(_taskId);
|
TaskChanged?.Invoke(_taskId);
|
||||||
}
|
}
|
||||||
@@ -241,7 +255,9 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
OrderNum = Subtasks.Count,
|
OrderNum = Subtasks.Count,
|
||||||
CreatedAt = DateTime.UtcNow,
|
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);
|
var vm = SubtaskItemViewModel.From(entity);
|
||||||
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
||||||
Subtasks.Add(vm);
|
Subtasks.Add(vm);
|
||||||
@@ -251,7 +267,11 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
private async Task RemoveSubtask(SubtaskItemViewModel item)
|
private async Task RemoveSubtask(SubtaskItemViewModel item)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(item.Id))
|
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;
|
item.PropertyChanged -= OnSubtaskPropertyChanged;
|
||||||
Subtasks.Remove(item);
|
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;
|
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _subtaskRepo.UpdateAsync(new SubtaskEntity
|
using var context = _dbFactory.CreateDbContext();
|
||||||
|
var subtaskRepo = new SubtaskRepository(context);
|
||||||
|
await subtaskRepo.UpdateAsync(new SubtaskEntity
|
||||||
{
|
{
|
||||||
Id = vm.Id,
|
Id = vm.Id,
|
||||||
TaskId = _taskId ?? "",
|
TaskId = _taskId ?? "",
|
||||||
@@ -321,7 +343,9 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
|
|
||||||
private async Task LoadWorktreeAsync(string taskId)
|
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;
|
HasWorktree = wt is not null;
|
||||||
if (wt is not null)
|
if (wt is not null)
|
||||||
{
|
{
|
||||||
@@ -378,14 +402,27 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
private async Task MergeIntoMainAsync()
|
private async Task MergeIntoMainAsync()
|
||||||
{
|
{
|
||||||
if (_taskId is null || _listId is null) return;
|
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;
|
if (wt is null || list?.WorkingDir is null) return;
|
||||||
|
|
||||||
await _git.MergeFfOnlyAsync(list.WorkingDir, wt.BranchName);
|
await _git.MergeFfOnlyAsync(list.WorkingDir, wt.BranchName);
|
||||||
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
||||||
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, 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);
|
await LoadWorktreeAsync(_taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,12 +430,25 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
private async Task KeepAsBranchAsync()
|
private async Task KeepAsBranchAsync()
|
||||||
{
|
{
|
||||||
if (_taskId is null || _listId is null) return;
|
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;
|
if (wt is null || list?.WorkingDir is null) return;
|
||||||
|
|
||||||
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
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);
|
await LoadWorktreeAsync(_taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,13 +456,26 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
private async Task DiscardAsync()
|
private async Task DiscardAsync()
|
||||||
{
|
{
|
||||||
if (_taskId is null || _listId is null) return;
|
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;
|
if (wt is null || list?.WorkingDir is null) return;
|
||||||
|
|
||||||
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
||||||
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, 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);
|
await LoadWorktreeAsync(_taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
namespace ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
public partial class TaskEditorViewModel : ViewModelBase
|
public partial class TaskEditorViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly SubtaskRepository _subtaskRepo;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
|
||||||
[ObservableProperty] private string _title = "";
|
[ObservableProperty] private string _title = "";
|
||||||
[ObservableProperty] private string? _description;
|
[ObservableProperty] private string? _description;
|
||||||
@@ -40,9 +42,9 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
public static string[] StatusChoices { get; } =
|
public static string[] StatusChoices { get; } =
|
||||||
["manual", "queued"];
|
["manual", "queued"];
|
||||||
|
|
||||||
public TaskEditorViewModel(SubtaskRepository subtaskRepo)
|
public TaskEditorViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||||
{
|
{
|
||||||
_subtaskRepo = subtaskRepo;
|
_dbFactory = dbFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadAgentsAsync(WorkerClient worker)
|
public async Task LoadAgentsAsync(WorkerClient worker)
|
||||||
@@ -116,7 +118,9 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
WindowTitle = $"Edit Task: {entity.Title}";
|
WindowTitle = $"Edit Task: {entity.Title}";
|
||||||
|
|
||||||
Subtasks.Clear();
|
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)
|
foreach (var s in list)
|
||||||
Subtasks.Add(SubtaskItemViewModel.From(s));
|
Subtasks.Add(SubtaskItemViewModel.From(s));
|
||||||
}
|
}
|
||||||
@@ -196,36 +200,42 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
// Persist subtask changes
|
// Persist subtask changes
|
||||||
if (_editId is not null)
|
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 existingIds = existing.Select(s => s.Id).ToHashSet();
|
||||||
var currentIds = Subtasks.Where(s => s.Id != "").Select(s => s.Id).ToHashSet();
|
var currentIds = Subtasks.Where(s => s.Id != "").Select(s => s.Id).ToHashSet();
|
||||||
|
|
||||||
// Deleted
|
// Deleted
|
||||||
foreach (var id in existingIds.Except(currentIds))
|
foreach (var id in existingIds.Except(currentIds))
|
||||||
await _subtaskRepo.DeleteAsync(id);
|
await subtaskRepo.DeleteAsync(id);
|
||||||
|
|
||||||
// Updated
|
// Updated
|
||||||
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)))
|
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)))
|
||||||
{
|
{
|
||||||
if (vm.Id == "") continue;
|
if (vm.Id == "") continue;
|
||||||
if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted)
|
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
|
else
|
||||||
{
|
{
|
||||||
// update order_num if position changed
|
// update order_num if position changed
|
||||||
var orig = existing.FirstOrDefault(e => e.Id == vm.Id);
|
var orig = existing.FirstOrDefault(e => e.Id == vm.Id);
|
||||||
if (orig is not null && orig.OrderNum != idx)
|
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)
|
// 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 == ""))
|
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == ""))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(vm.Title)) continue;
|
if (string.IsNullOrWhiteSpace(vm.Title)) continue;
|
||||||
var newId = Guid.NewGuid().ToString();
|
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);
|
_tcs.TrySetResult(entity);
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ using System.Collections.ObjectModel;
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.Views;
|
using ClaudeDo.Ui.Views;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
namespace ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
public partial class TaskListViewModel : ViewModelBase
|
public partial class TaskListViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly TaskRepository _taskRepo;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly TagRepository _tagRepo;
|
|
||||||
private readonly ListRepository _listRepo;
|
|
||||||
private readonly WorkerClient _worker;
|
private readonly WorkerClient _worker;
|
||||||
private readonly Func<TaskEditorViewModel> _editorFactory;
|
private readonly Func<TaskEditorViewModel> _editorFactory;
|
||||||
private readonly Action<string> _showMessage;
|
private readonly Action<string> _showMessage;
|
||||||
@@ -33,13 +33,10 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
partial void OnSelectedTaskChanged(TaskItemViewModel? value) =>
|
partial void OnSelectedTaskChanged(TaskItemViewModel? value) =>
|
||||||
SelectedTaskChanged?.Invoke(value);
|
SelectedTaskChanged?.Invoke(value);
|
||||||
|
|
||||||
public TaskListViewModel(TaskRepository taskRepo, TagRepository tagRepo,
|
public TaskListViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker,
|
||||||
ListRepository listRepo, WorkerClient worker,
|
|
||||||
Func<TaskEditorViewModel> editorFactory, Action<string> showMessage)
|
Func<TaskEditorViewModel> editorFactory, Action<string> showMessage)
|
||||||
{
|
{
|
||||||
_taskRepo = taskRepo;
|
_dbFactory = dbFactory;
|
||||||
_tagRepo = tagRepo;
|
|
||||||
_listRepo = listRepo;
|
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_editorFactory = editorFactory;
|
_editorFactory = editorFactory;
|
||||||
_showMessage = showMessage;
|
_showMessage = showMessage;
|
||||||
@@ -77,7 +74,9 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
|
|
||||||
if (listId is not null)
|
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";
|
ListName = list?.Name ?? "Tasks";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -89,10 +88,12 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var entities = await _taskRepo.GetByListAsync(listId);
|
using var context = _dbFactory.CreateDbContext();
|
||||||
|
var taskRepo = new TaskRepository(context);
|
||||||
|
var entities = await taskRepo.GetByListIdAsync(listId);
|
||||||
foreach (var e in entities)
|
foreach (var e in entities)
|
||||||
{
|
{
|
||||||
var tags = await _taskRepo.GetEffectiveTagsAsync(e.Id);
|
var tags = await taskRepo.GetEffectiveTagsAsync(e.Id);
|
||||||
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync));
|
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,8 +111,13 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
var title = InlineAddTitle.Trim();
|
var title = InlineAddTitle.Trim();
|
||||||
if (string.IsNullOrEmpty(title) || CurrentListId is null) return;
|
if (string.IsNullOrEmpty(title) || CurrentListId is null) return;
|
||||||
|
|
||||||
var list = await _listRepo.GetByIdAsync(CurrentListId);
|
string defaultCommitType;
|
||||||
var defaultCommitType = list?.DefaultCommitType ?? "chore";
|
using (var context = _dbFactory.CreateDbContext())
|
||||||
|
{
|
||||||
|
var listRepo = new ListRepository(context);
|
||||||
|
var list = await listRepo.GetByIdAsync(CurrentListId);
|
||||||
|
defaultCommitType = list?.DefaultCommitType ?? "chore";
|
||||||
|
}
|
||||||
|
|
||||||
var entity = new TaskEntity
|
var entity = new TaskEntity
|
||||||
{
|
{
|
||||||
@@ -125,8 +131,10 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _taskRepo.AddAsync(entity);
|
using var context = _dbFactory.CreateDbContext();
|
||||||
var tags = await _taskRepo.GetEffectiveTagsAsync(entity.Id);
|
var taskRepo = new TaskRepository(context);
|
||||||
|
await taskRepo.AddAsync(entity);
|
||||||
|
var tags = await taskRepo.GetEffectiveTagsAsync(entity.Id);
|
||||||
var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync);
|
var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync);
|
||||||
Tasks.Add(vm);
|
Tasks.Add(vm);
|
||||||
SelectedTask = vm;
|
SelectedTask = vm;
|
||||||
@@ -141,9 +149,13 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
[RelayCommand(CanExecute = nameof(CanAddTask))]
|
[RelayCommand(CanExecute = nameof(CanAddTask))]
|
||||||
private async Task AddTask()
|
private async Task AddTask()
|
||||||
{
|
{
|
||||||
// Get list default commit type
|
string defaultCommitType;
|
||||||
var list = await _listRepo.GetByIdAsync(CurrentListId);
|
using (var context = _dbFactory.CreateDbContext())
|
||||||
var defaultCommitType = list?.DefaultCommitType ?? "chore";
|
{
|
||||||
|
var listRepo = new ListRepository(context);
|
||||||
|
var list = await listRepo.GetByIdAsync(CurrentListId);
|
||||||
|
defaultCommitType = list?.DefaultCommitType ?? "chore";
|
||||||
|
}
|
||||||
|
|
||||||
var editor = _editorFactory();
|
var editor = _editorFactory();
|
||||||
await editor.LoadAgentsAsync(_worker);
|
await editor.LoadAgentsAsync(_worker);
|
||||||
@@ -159,15 +171,18 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
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)
|
foreach (var tagName in editor.SelectedTagNames)
|
||||||
{
|
{
|
||||||
var tagId = await _tagRepo.GetOrCreateAsync(tagName);
|
var tagId = await tagRepo.GetOrCreateAsync(tagName);
|
||||||
await _taskRepo.AddTagAsync(saved.Id, tagId);
|
await taskRepo.AddTagAsync(saved.Id, tagId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var tags = await _taskRepo.GetEffectiveTagsAsync(saved.Id);
|
var tags = await taskRepo.GetEffectiveTagsAsync(saved.Id);
|
||||||
Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync));
|
Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync));
|
||||||
|
|
||||||
// Auto wake-queue if agent+queued
|
// Auto wake-queue if agent+queued
|
||||||
@@ -188,10 +203,17 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
private async Task EditTask()
|
private async Task EditTask()
|
||||||
{
|
{
|
||||||
if (SelectedTask is null || CurrentListId is null) return;
|
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();
|
var editor = _editorFactory();
|
||||||
await editor.LoadAgentsAsync(_worker);
|
await editor.LoadAgentsAsync(_worker);
|
||||||
await editor.InitForEditAsync(entity, taskTags);
|
await editor.InitForEditAsync(entity, taskTags);
|
||||||
@@ -206,18 +228,21 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
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)
|
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)
|
foreach (var tagName in editor.SelectedTagNames)
|
||||||
{
|
{
|
||||||
var tagId = await _tagRepo.GetOrCreateAsync(tagName);
|
var tagId = await tagRepo.GetOrCreateAsync(tagName);
|
||||||
await _taskRepo.AddTagAsync(saved.Id, tagId);
|
await taskRepo.AddTagAsync(saved.Id, tagId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var newTags = await _taskRepo.GetEffectiveTagsAsync(saved.Id);
|
var newTags = await taskRepo.GetEffectiveTagsAsync(saved.Id);
|
||||||
SelectedTask.Refresh(saved, newTags);
|
SelectedTask.Refresh(saved, newTags);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -232,7 +257,9 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
if (SelectedTask is null) return;
|
if (SelectedTask is null) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _taskRepo.DeleteAsync(SelectedTask.Id);
|
using var context = _dbFactory.CreateDbContext();
|
||||||
|
var taskRepo = new TaskRepository(context);
|
||||||
|
await taskRepo.DeleteAsync(SelectedTask.Id);
|
||||||
Tasks.Remove(SelectedTask);
|
Tasks.Remove(SelectedTask);
|
||||||
SelectedTask = null;
|
SelectedTask = null;
|
||||||
}
|
}
|
||||||
@@ -244,14 +271,16 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
|
|
||||||
public async Task RefreshSingleAsync(string taskId)
|
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);
|
var existing = Tasks.FirstOrDefault(t => t.Id == taskId);
|
||||||
if (entity is null)
|
if (entity is null)
|
||||||
{
|
{
|
||||||
if (existing is not null) Tasks.Remove(existing);
|
if (existing is not null) Tasks.Remove(existing);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var tags = await _taskRepo.GetEffectiveTagsAsync(taskId);
|
var tags = await taskRepo.GetEffectiveTagsAsync(taskId);
|
||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
existing.Refresh(entity, tags);
|
existing.Refresh(entity, tags);
|
||||||
}
|
}
|
||||||
@@ -270,14 +299,16 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
|
|
||||||
private async Task ToggleDoneAsync(string taskId)
|
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;
|
if (entity is null) return;
|
||||||
|
|
||||||
entity.Status = entity.Status == TaskStatus.Done ? TaskStatus.Manual : TaskStatus.Done;
|
entity.Status = entity.Status == TaskStatus.Done ? TaskStatus.Manual : TaskStatus.Done;
|
||||||
if (entity.Status == TaskStatus.Done)
|
if (entity.Status == TaskStatus.Done)
|
||||||
entity.FinishedAt = DateTime.UtcNow;
|
entity.FinishedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
await _taskRepo.UpdateAsync(entity);
|
await taskRepo.UpdateAsync(entity);
|
||||||
await RefreshSingleAsync(taskId);
|
await RefreshSingleAsync(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using ClaudeDo.Worker.Config;
|
|||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
using ClaudeDo.Worker.Runner;
|
using ClaudeDo.Worker.Runner;
|
||||||
using ClaudeDo.Worker.Services;
|
using ClaudeDo.Worker.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
var cfg = WorkerConfig.Load();
|
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.
|
// doesn't think we crashed (~30s timeout). No-op when running interactively.
|
||||||
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
|
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
|
||||||
|
|
||||||
// Initialize DB schema before the host starts accepting connections.
|
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
||||||
var dbFactory = new SqliteConnectionFactory(cfg.DbPath);
|
opt.UseSqlite($"Data Source={cfg.DbPath}"));
|
||||||
SchemaInitializer.Apply(dbFactory);
|
|
||||||
|
|
||||||
builder.Services.AddSingleton(cfg);
|
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.AddHostedService<StaleTaskRecovery>();
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
@@ -51,6 +44,12 @@ builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
ClaudeDoDbContext.MigrateAndConfigure(
|
||||||
|
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
|
||||||
|
}
|
||||||
|
|
||||||
app.MapHub<WorkerHub>("/hub");
|
app.MapHub<WorkerHub>("/hub");
|
||||||
|
|
||||||
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Config;
|
using ClaudeDo.Worker.Config;
|
||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Runner;
|
namespace ClaudeDo.Worker.Runner;
|
||||||
|
|
||||||
public sealed class TaskRunner
|
public sealed class TaskRunner
|
||||||
{
|
{
|
||||||
private readonly IClaudeProcess _claude;
|
private readonly IClaudeProcess _claude;
|
||||||
private readonly TaskRepository _taskRepo;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly TaskRunRepository _runRepo;
|
|
||||||
private readonly ListRepository _listRepo;
|
|
||||||
private readonly WorktreeRepository _wtRepo;
|
|
||||||
private readonly SubtaskRepository _subtaskRepo;
|
|
||||||
private readonly HubBroadcaster _broadcaster;
|
private readonly HubBroadcaster _broadcaster;
|
||||||
private readonly WorktreeManager _wtManager;
|
private readonly WorktreeManager _wtManager;
|
||||||
private readonly ClaudeArgsBuilder _argsBuilder;
|
private readonly ClaudeArgsBuilder _argsBuilder;
|
||||||
@@ -21,11 +19,7 @@ public sealed class TaskRunner
|
|||||||
|
|
||||||
public TaskRunner(
|
public TaskRunner(
|
||||||
IClaudeProcess claude,
|
IClaudeProcess claude,
|
||||||
TaskRepository taskRepo,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
TaskRunRepository runRepo,
|
|
||||||
ListRepository listRepo,
|
|
||||||
WorktreeRepository wtRepo,
|
|
||||||
SubtaskRepository subtaskRepo,
|
|
||||||
HubBroadcaster broadcaster,
|
HubBroadcaster broadcaster,
|
||||||
WorktreeManager wtManager,
|
WorktreeManager wtManager,
|
||||||
ClaudeArgsBuilder argsBuilder,
|
ClaudeArgsBuilder argsBuilder,
|
||||||
@@ -33,11 +27,7 @@ public sealed class TaskRunner
|
|||||||
ILogger<TaskRunner> logger)
|
ILogger<TaskRunner> logger)
|
||||||
{
|
{
|
||||||
_claude = claude;
|
_claude = claude;
|
||||||
_taskRepo = taskRepo;
|
_dbFactory = dbFactory;
|
||||||
_runRepo = runRepo;
|
|
||||||
_listRepo = listRepo;
|
|
||||||
_wtRepo = wtRepo;
|
|
||||||
_subtaskRepo = subtaskRepo;
|
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
_wtManager = wtManager;
|
_wtManager = wtManager;
|
||||||
_argsBuilder = argsBuilder;
|
_argsBuilder = argsBuilder;
|
||||||
@@ -49,12 +39,24 @@ public sealed class TaskRunner
|
|||||||
{
|
{
|
||||||
try
|
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)
|
if (list is null)
|
||||||
{
|
{
|
||||||
await MarkFailed(task.Id, slot, "List not found.");
|
await MarkFailed(task.Id, slot, "List not found.");
|
||||||
return;
|
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.
|
// Determine working directory: worktree or sandbox.
|
||||||
WorktreeContext? wtCtx = null;
|
WorktreeContext? wtCtx = null;
|
||||||
@@ -81,7 +83,6 @@ public sealed class TaskRunner
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve config: task overrides > list config > null.
|
// Resolve config: task overrides > list config > null.
|
||||||
var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
|
|
||||||
var resolvedConfig = new ClaudeRunConfig(
|
var resolvedConfig = new ClaudeRunConfig(
|
||||||
Model: task.Model ?? listConfig?.Model ?? "claude-sonnet-4-6",
|
Model: task.Model ?? listConfig?.Model ?? "claude-sonnet-4-6",
|
||||||
SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
|
SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
|
||||||
@@ -90,11 +91,14 @@ public sealed class TaskRunner
|
|||||||
);
|
);
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
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);
|
await _broadcaster.TaskStarted(slot, task.Id, now);
|
||||||
|
|
||||||
// Build prompt.
|
// Build prompt.
|
||||||
var subtasks = await _subtaskRepo.GetByTaskIdAsync(task.Id, ct);
|
|
||||||
var sb = new System.Text.StringBuilder(task.Title);
|
var sb = new System.Text.StringBuilder(task.Title);
|
||||||
if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim());
|
if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim());
|
||||||
if (subtasks.Count > 0)
|
if (subtasks.Count > 0)
|
||||||
@@ -155,19 +159,34 @@ public sealed class TaskRunner
|
|||||||
|
|
||||||
public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
|
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.");
|
?? 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.");
|
?? throw new InvalidOperationException("No previous run to continue.");
|
||||||
|
|
||||||
if (lastRun.SessionId is null)
|
if (lastRun.SessionId is null)
|
||||||
throw new InvalidOperationException("Previous run has no session ID — cannot resume.");
|
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.");
|
?? 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(
|
var resolvedConfig = new ClaudeRunConfig(
|
||||||
Model: task.Model ?? listConfig?.Model,
|
Model: task.Model ?? listConfig?.Model,
|
||||||
SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
|
SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
|
||||||
@@ -178,7 +197,6 @@ public sealed class TaskRunner
|
|||||||
// Determine run directory from existing worktree or sandbox.
|
// Determine run directory from existing worktree or sandbox.
|
||||||
string runDir;
|
string runDir;
|
||||||
WorktreeContext? wtCtx = null;
|
WorktreeContext? wtCtx = null;
|
||||||
var worktree = await _wtRepo.GetByTaskIdAsync(taskId, ct);
|
|
||||||
if (worktree is not null)
|
if (worktree is not null)
|
||||||
{
|
{
|
||||||
runDir = worktree.Path;
|
runDir = worktree.Path;
|
||||||
@@ -190,7 +208,11 @@ public sealed class TaskRunner
|
|||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
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);
|
await _broadcaster.TaskStarted(slot, taskId, now);
|
||||||
|
|
||||||
var nextRunNumber = lastRun.RunNumber + 1;
|
var nextRunNumber = lastRun.RunNumber + 1;
|
||||||
@@ -226,7 +248,12 @@ public sealed class TaskRunner
|
|||||||
LogPath = logPath,
|
LogPath = logPath,
|
||||||
StartedAt = DateTime.UtcNow,
|
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);
|
var arguments = _argsBuilder.Build(config);
|
||||||
|
|
||||||
@@ -257,10 +284,15 @@ public sealed class TaskRunner
|
|||||||
run.TokensIn = result.TokensIn;
|
run.TokensIn = result.TokensIn;
|
||||||
run.TokensOut = result.TokensOut;
|
run.TokensOut = result.TokensOut;
|
||||||
run.FinishedAt = DateTime.UtcNow;
|
run.FinishedAt = DateTime.UtcNow;
|
||||||
await _runRepo.UpdateAsync(run, CancellationToken.None);
|
|
||||||
|
|
||||||
// Update denormalized fields on the task.
|
using (var context = _dbFactory.CreateDbContext())
|
||||||
await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
|
{
|
||||||
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -273,8 +305,12 @@ public sealed class TaskRunner
|
|||||||
run.FinishedAt = DateTime.UtcNow;
|
run.FinishedAt = DateTime.UtcNow;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _runRepo.UpdateAsync(run, CancellationToken.None);
|
using var context = _dbFactory.CreateDbContext();
|
||||||
await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
|
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)
|
catch (Exception updateEx)
|
||||||
{
|
{
|
||||||
@@ -297,7 +333,11 @@ public sealed class TaskRunner
|
|||||||
// is never left as 'running' because of a cancel that arrived
|
// is never left as 'running' because of a cancel that arrived
|
||||||
// after the Claude run already succeeded.
|
// after the Claude run already succeeded.
|
||||||
var finishedAt = DateTime.UtcNow;
|
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);
|
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
||||||
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
|
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
|
||||||
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
|
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
|
// Intentionally does not accept a CancellationToken: this is the
|
||||||
// terminal write for a failed task and must always be persisted.
|
// terminal write for a failed task and must always be persisted.
|
||||||
var finishedAt = DateTime.UtcNow;
|
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);
|
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
|
||||||
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
|
_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;
|
var now = DateTime.UtcNow;
|
||||||
// Terminal write — never cancel.
|
// 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.TaskFinished(slot, taskId, "failed", now);
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Config;
|
using ClaudeDo.Worker.Config;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Runner;
|
namespace ClaudeDo.Worker.Runner;
|
||||||
|
|
||||||
@@ -10,14 +12,14 @@ public sealed record WorktreeContext(string WorktreePath, string BranchName, str
|
|||||||
public sealed class WorktreeManager
|
public sealed class WorktreeManager
|
||||||
{
|
{
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
private readonly WorktreeRepository _wtRepo;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly WorkerConfig _cfg;
|
private readonly WorkerConfig _cfg;
|
||||||
private readonly ILogger<WorktreeManager> _logger;
|
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;
|
_git = git;
|
||||||
_wtRepo = wtRepo;
|
_dbFactory = dbFactory;
|
||||||
_cfg = cfg;
|
_cfg = cfg;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -50,7 +52,9 @@ public sealed class WorktreeManager
|
|||||||
await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct);
|
await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct);
|
||||||
|
|
||||||
// Insert worktrees row AFTER git succeeds — if git throws, no row is created.
|
// 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,
|
TaskId = task.Id,
|
||||||
Path = worktreePath,
|
Path = worktreePath,
|
||||||
@@ -87,7 +91,9 @@ public sealed class WorktreeManager
|
|||||||
var head = await _git.RevParseHeadAsync(ctx.WorktreePath, ct);
|
var head = await _git.RevParseHeadAsync(ctx.WorktreePath, ct);
|
||||||
var diffStat = await _git.DiffStatAsync(ctx.WorktreePath, ctx.BaseCommit, head, 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);
|
_logger.LogInformation("Committed changes for task {TaskId}: {Head}", task.Id, head);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Config;
|
using ClaudeDo.Worker.Config;
|
||||||
using ClaudeDo.Worker.Runner;
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Services;
|
namespace ClaudeDo.Worker.Services;
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ public sealed class QueueSlotState
|
|||||||
|
|
||||||
public sealed class QueueService : BackgroundService
|
public sealed class QueueService : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly TaskRepository _taskRepo;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly TaskRunner _runner;
|
private readonly TaskRunner _runner;
|
||||||
private readonly WorkerConfig _cfg;
|
private readonly WorkerConfig _cfg;
|
||||||
private readonly ILogger<QueueService> _logger;
|
private readonly ILogger<QueueService> _logger;
|
||||||
@@ -26,12 +28,12 @@ public sealed class QueueService : BackgroundService
|
|||||||
private readonly SemaphoreSlim _wakeSignal = new(0, 1);
|
private readonly SemaphoreSlim _wakeSignal = new(0, 1);
|
||||||
|
|
||||||
public QueueService(
|
public QueueService(
|
||||||
TaskRepository taskRepo,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
TaskRunner runner,
|
TaskRunner runner,
|
||||||
WorkerConfig cfg,
|
WorkerConfig cfg,
|
||||||
ILogger<QueueService> logger)
|
ILogger<QueueService> logger)
|
||||||
{
|
{
|
||||||
_taskRepo = taskRepo;
|
_dbFactory = dbFactory;
|
||||||
_runner = runner;
|
_runner = runner;
|
||||||
_cfg = cfg;
|
_cfg = cfg;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -56,7 +58,9 @@ public sealed class QueueService : BackgroundService
|
|||||||
|
|
||||||
public async Task RunNow(string taskId)
|
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)
|
if (task is null)
|
||||||
throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
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)
|
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.");
|
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||||
|
|
||||||
if (task.Status == Data.Models.TaskStatus.Running)
|
if (task.Status == Data.Models.TaskStatus.Running)
|
||||||
@@ -144,7 +150,12 @@ public sealed class QueueService : BackgroundService
|
|||||||
|
|
||||||
if (_queueSlot is not null) continue;
|
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;
|
if (task is null) continue;
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Services;
|
namespace ClaudeDo.Worker.Services;
|
||||||
|
|
||||||
public sealed class StaleTaskRecovery : IHostedService
|
public sealed class StaleTaskRecovery : IHostedService
|
||||||
{
|
{
|
||||||
private readonly TaskRepository _tasks;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly ILogger<StaleTaskRecovery> _logger;
|
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;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
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)
|
if (flipped > 0)
|
||||||
_logger.LogWarning("Stale task recovery: flipped {Count} running task(s) to failed", flipped);
|
_logger.LogWarning("Stale task recovery: flipped {Count} running task(s) to failed", flipped);
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
<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="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
<PackageReference Include="xunit" Version="2.5.3" />
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Tests.Infrastructure;
|
namespace ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
|
||||||
public sealed class DbFixture : IDisposable
|
public sealed class DbFixture : IDisposable
|
||||||
{
|
{
|
||||||
public string DbPath { get; }
|
public string DbPath { get; }
|
||||||
public SqliteConnectionFactory Factory { get; }
|
|
||||||
|
|
||||||
public DbFixture()
|
public DbFixture()
|
||||||
{
|
{
|
||||||
DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db");
|
DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db");
|
||||||
Factory = new SqliteConnectionFactory(DbPath);
|
// Apply migrations so the schema is created.
|
||||||
SchemaInitializer.Apply(Factory);
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
try { File.Delete(DbPath); } catch { /* best effort */ }
|
try { File.Delete(DbPath); } catch { /* best effort */ }
|
||||||
@@ -21,3 +32,10 @@ public sealed class DbFixture : IDisposable
|
|||||||
try { File.Delete(DbPath + "-shm"); } catch { }
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
@@ -7,12 +8,14 @@ namespace ClaudeDo.Worker.Tests.Repositories;
|
|||||||
public sealed class ListRepositoryConfigTests : IDisposable
|
public sealed class ListRepositoryConfigTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly DbFixture _db = new();
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
private readonly ListRepository _repo;
|
private readonly ListRepository _repo;
|
||||||
private readonly string _listId;
|
private readonly string _listId;
|
||||||
|
|
||||||
public ListRepositoryConfigTests()
|
public ListRepositoryConfigTests()
|
||||||
{
|
{
|
||||||
_repo = new ListRepository(_db.Factory);
|
_ctx = _db.CreateContext();
|
||||||
|
_repo = new ListRepository(_ctx);
|
||||||
_listId = Guid.NewGuid().ToString();
|
_listId = Guid.NewGuid().ToString();
|
||||||
_repo.AddAsync(new ListEntity
|
_repo.AddAsync(new ListEntity
|
||||||
{
|
{
|
||||||
@@ -57,5 +60,9 @@ public sealed class ListRepositoryConfigTests : IDisposable
|
|||||||
Assert.Equal("haiku-4-5", fetched.Model);
|
Assert.Equal("haiku-4-5", fetched.Model);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => _db.Dispose();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
@@ -7,16 +8,22 @@ namespace ClaudeDo.Worker.Tests.Repositories;
|
|||||||
public sealed class ListRepositoryTests : IDisposable
|
public sealed class ListRepositoryTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly DbFixture _db = new();
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
private readonly ListRepository _lists;
|
private readonly ListRepository _lists;
|
||||||
private readonly TagRepository _tags;
|
private readonly TagRepository _tags;
|
||||||
|
|
||||||
public ListRepositoryTests()
|
public ListRepositoryTests()
|
||||||
{
|
{
|
||||||
_lists = new ListRepository(_db.Factory);
|
_ctx = _db.CreateContext();
|
||||||
_tags = new TagRepository(_db.Factory);
|
_lists = new ListRepository(_ctx);
|
||||||
|
_tags = new TagRepository(_ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => _db.Dispose();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddAsync_And_GetByIdAsync_Roundtrips()
|
public async Task AddAsync_And_GetByIdAsync_Roundtrips()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
@@ -8,18 +9,24 @@ namespace ClaudeDo.Worker.Tests.Repositories;
|
|||||||
public sealed class TaskRepositoryTests : IDisposable
|
public sealed class TaskRepositoryTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly DbFixture _db = new();
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
private readonly TaskRepository _tasks;
|
private readonly TaskRepository _tasks;
|
||||||
private readonly ListRepository _lists;
|
private readonly ListRepository _lists;
|
||||||
private readonly TagRepository _tags;
|
private readonly TagRepository _tags;
|
||||||
|
|
||||||
public TaskRepositoryTests()
|
public TaskRepositoryTests()
|
||||||
{
|
{
|
||||||
_tasks = new TaskRepository(_db.Factory);
|
_ctx = _db.CreateContext();
|
||||||
_lists = new ListRepository(_db.Factory);
|
_tasks = new TaskRepository(_ctx);
|
||||||
_tags = new TagRepository(_db.Factory);
|
_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)
|
private async Task<string> CreateListAsync(string? id = null)
|
||||||
{
|
{
|
||||||
@@ -197,7 +204,7 @@ public sealed class TaskRepositoryTests : IDisposable
|
|||||||
var listId = await CreateListAsync();
|
var listId = await CreateListAsync();
|
||||||
var agentTagId = await _tags.GetOrCreateAsync("agent");
|
var agentTagId = await _tags.GetOrCreateAsync("agent");
|
||||||
var manualTagId = await _tags.GetOrCreateAsync("manual");
|
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);
|
await _lists.AddTagAsync(listId, agentTagId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
@@ -7,16 +8,18 @@ namespace ClaudeDo.Worker.Tests.Repositories;
|
|||||||
public sealed class TaskRunRepositoryTests : IDisposable
|
public sealed class TaskRunRepositoryTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly DbFixture _db = new();
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
private readonly TaskRunRepository _runs;
|
private readonly TaskRunRepository _runs;
|
||||||
private readonly string _taskId;
|
private readonly string _taskId;
|
||||||
|
|
||||||
public TaskRunRepositoryTests()
|
public TaskRunRepositoryTests()
|
||||||
{
|
{
|
||||||
_runs = new TaskRunRepository(_db.Factory);
|
_ctx = _db.CreateContext();
|
||||||
|
_runs = new TaskRunRepository(_ctx);
|
||||||
|
|
||||||
// Seed a list and task for all tests
|
// Seed a list and task for all tests
|
||||||
var lists = new ListRepository(_db.Factory);
|
var lists = new ListRepository(_ctx);
|
||||||
var tasks = new TaskRepository(_db.Factory);
|
var tasks = new TaskRepository(_ctx);
|
||||||
var listId = Guid.NewGuid().ToString();
|
var listId = Guid.NewGuid().ToString();
|
||||||
lists.AddAsync(new ListEntity
|
lists.AddAsync(new ListEntity
|
||||||
{
|
{
|
||||||
@@ -37,7 +40,11 @@ public sealed class TaskRunRepositoryTests : IDisposable
|
|||||||
}).GetAwaiter().GetResult();
|
}).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => _db.Dispose();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private TaskRunEntity MakeRun(int runNumber, bool isRetry = false) => new()
|
private TaskRunEntity MakeRun(int runNumber, bool isRetry = false) => new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
@@ -24,19 +25,19 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
return f;
|
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)
|
TaskEntity task, ListEntity list, string strategy = "sibling", string? centralRoot = null)
|
||||||
{
|
{
|
||||||
var db = new DbFixture();
|
var db = new DbFixture();
|
||||||
_dbFixtures.Add(db);
|
_dbFixtures.Add(db);
|
||||||
|
|
||||||
// Seed the DB with list and task so FK constraints pass.
|
// Seed the DB with list and task so FK constraints pass.
|
||||||
var listRepo = new ListRepository(db.Factory);
|
using var seedCtx = db.CreateContext();
|
||||||
var taskRepo = new TaskRepository(db.Factory);
|
var listRepo = new ListRepository(seedCtx);
|
||||||
|
var taskRepo = new TaskRepository(seedCtx);
|
||||||
await listRepo.AddAsync(list);
|
await listRepo.AddAsync(list);
|
||||||
await taskRepo.AddAsync(task);
|
await taskRepo.AddAsync(task);
|
||||||
|
|
||||||
var wtRepo = new WorktreeRepository(db.Factory);
|
|
||||||
var cfg = new WorkerConfig
|
var cfg = new WorkerConfig
|
||||||
{
|
{
|
||||||
WorktreeRootStrategy = strategy,
|
WorktreeRootStrategy = strategy,
|
||||||
@@ -45,8 +46,8 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
cfg.CentralWorktreeRoot = centralRoot;
|
cfg.CentralWorktreeRoot = centralRoot;
|
||||||
|
|
||||||
var mgr = new WorktreeManager(
|
var mgr = new WorktreeManager(
|
||||||
new GitService(), wtRepo, cfg, NullLogger<WorktreeManager>.Instance);
|
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
return (mgr, wtRepo);
|
return (mgr, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -56,7 +57,7 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
|
|
||||||
var repo = CreateRepo();
|
var repo = CreateRepo();
|
||||||
var (task, list) = MakeEntities(repo.RepoDir);
|
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);
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
||||||
@@ -66,6 +67,8 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
Assert.Equal($"claudedo/{task.Id.Replace("-", "")}", ctx.BranchName);
|
Assert.Equal($"claudedo/{task.Id.Replace("-", "")}", ctx.BranchName);
|
||||||
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
|
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
|
||||||
|
|
||||||
|
using var readCtx = db.CreateContext();
|
||||||
|
var wtRepo = new WorktreeRepository(readCtx);
|
||||||
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
||||||
Assert.NotNull(row);
|
Assert.NotNull(row);
|
||||||
Assert.Equal(WorktreeState.Active, row!.State);
|
Assert.Equal(WorktreeState.Active, row!.State);
|
||||||
@@ -80,7 +83,7 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
|
|
||||||
var repo = CreateRepo();
|
var repo = CreateRepo();
|
||||||
var (task, list) = MakeEntities(repo.RepoDir);
|
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);
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
||||||
@@ -88,6 +91,8 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
|
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(committed);
|
Assert.False(committed);
|
||||||
|
using var readCtx = db.CreateContext();
|
||||||
|
var wtRepo = new WorktreeRepository(readCtx);
|
||||||
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
||||||
Assert.Null(row!.HeadCommit);
|
Assert.Null(row!.HeadCommit);
|
||||||
}
|
}
|
||||||
@@ -99,7 +104,7 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
|
|
||||||
var repo = CreateRepo();
|
var repo = CreateRepo();
|
||||||
var (task, list) = MakeEntities(repo.RepoDir);
|
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);
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
||||||
@@ -109,6 +114,8 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
|
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
Assert.True(committed);
|
Assert.True(committed);
|
||||||
|
using var readCtx = db.CreateContext();
|
||||||
|
var wtRepo = new WorktreeRepository(readCtx);
|
||||||
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
||||||
Assert.NotNull(row!.HeadCommit);
|
Assert.NotNull(row!.HeadCommit);
|
||||||
Assert.NotEqual(ctx.BaseCommit, row.HeadCommit);
|
Assert.NotEqual(ctx.BaseCommit, row.HeadCommit);
|
||||||
@@ -129,20 +136,24 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
|
|
||||||
var db = new DbFixture();
|
var db = new DbFixture();
|
||||||
_dbFixtures.Add(db);
|
_dbFixtures.Add(db);
|
||||||
var listRepo = new ListRepository(db.Factory);
|
using (var seedCtx = db.CreateContext())
|
||||||
var taskRepo = new TaskRepository(db.Factory);
|
{
|
||||||
|
var listRepo = new ListRepository(seedCtx);
|
||||||
|
var taskRepo = new TaskRepository(seedCtx);
|
||||||
await listRepo.AddAsync(list);
|
await listRepo.AddAsync(list);
|
||||||
await taskRepo.AddAsync(task);
|
await taskRepo.AddAsync(task);
|
||||||
|
}
|
||||||
|
|
||||||
var wtRepo = new WorktreeRepository(db.Factory);
|
|
||||||
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
||||||
var mgr = new WorktreeManager(
|
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>(
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => mgr.CreateAsync(task, list, CancellationToken.None));
|
() => mgr.CreateAsync(task, list, CancellationToken.None));
|
||||||
Assert.Contains("not a git repository", ex.Message);
|
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);
|
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
||||||
Assert.Null(row);
|
Assert.Null(row);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
@@ -15,6 +16,7 @@ namespace ClaudeDo.Worker.Tests.Services;
|
|||||||
public sealed class QueueServiceTests : IDisposable
|
public sealed class QueueServiceTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly DbFixture _db = new();
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
private readonly TaskRepository _taskRepo;
|
private readonly TaskRepository _taskRepo;
|
||||||
private readonly ListRepository _listRepo;
|
private readonly ListRepository _listRepo;
|
||||||
private readonly TagRepository _tagRepo;
|
private readonly TagRepository _tagRepo;
|
||||||
@@ -23,9 +25,10 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
|
|
||||||
public QueueServiceTests()
|
public QueueServiceTests()
|
||||||
{
|
{
|
||||||
_taskRepo = new TaskRepository(_db.Factory);
|
_ctx = _db.CreateContext();
|
||||||
_listRepo = new ListRepository(_db.Factory);
|
_taskRepo = new TaskRepository(_ctx);
|
||||||
_tagRepo = new TagRepository(_db.Factory);
|
_listRepo = new ListRepository(_ctx);
|
||||||
|
_tagRepo = new TagRepository(_ctx);
|
||||||
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}");
|
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}");
|
||||||
Directory.CreateDirectory(_tempDir);
|
Directory.CreateDirectory(_tempDir);
|
||||||
_cfg = new WorkerConfig
|
_cfg = new WorkerConfig
|
||||||
@@ -38,6 +41,7 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
_db.Dispose();
|
_db.Dispose();
|
||||||
try { Directory.Delete(_tempDir, true); } catch { }
|
try { Directory.Delete(_tempDir, true); } catch { }
|
||||||
}
|
}
|
||||||
@@ -47,14 +51,12 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
var fake = new FakeClaudeProcess(handler);
|
var fake = new FakeClaudeProcess(handler);
|
||||||
var broadcaster = new HubBroadcaster(new FakeHubContext());
|
var broadcaster = new HubBroadcaster(new FakeHubContext());
|
||||||
var wtRepo = new WorktreeRepository(_db.Factory);
|
var dbFactory = _db.CreateFactory();
|
||||||
var runRepo = new TaskRunRepository(_db.Factory);
|
var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger<WorktreeManager>.Instance);
|
|
||||||
var argsBuilder = new ClaudeArgsBuilder();
|
var argsBuilder = new ClaudeArgsBuilder();
|
||||||
var subtaskRepo = new SubtaskRepository(_db.Factory);
|
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
|
||||||
var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, subtaskRepo, broadcaster, wtManager, argsBuilder, _cfg,
|
|
||||||
NullLogger<TaskRunner>.Instance);
|
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);
|
return (service, fake);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Services;
|
using ClaudeDo.Worker.Services;
|
||||||
@@ -10,16 +11,22 @@ namespace ClaudeDo.Worker.Tests.Services;
|
|||||||
public sealed class StaleTaskRecoveryTests : IDisposable
|
public sealed class StaleTaskRecoveryTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly DbFixture _db = new();
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
private readonly TaskRepository _tasks;
|
private readonly TaskRepository _tasks;
|
||||||
private readonly ListRepository _lists;
|
private readonly ListRepository _lists;
|
||||||
|
|
||||||
public StaleTaskRecoveryTests()
|
public StaleTaskRecoveryTests()
|
||||||
{
|
{
|
||||||
_tasks = new TaskRepository(_db.Factory);
|
_ctx = _db.CreateContext();
|
||||||
_lists = new ListRepository(_db.Factory);
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => _db.Dispose();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StartAsync_Flips_Running_Tasks_To_Failed()
|
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(running);
|
||||||
await _tasks.AddAsync(queued);
|
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);
|
await recovery.StartAsync(CancellationToken.None);
|
||||||
|
|
||||||
var r = await _tasks.GetByIdAsync(running.Id);
|
var r = await _tasks.GetByIdAsync(running.Id);
|
||||||
|
|||||||
Reference in New Issue
Block a user