30 Commits

Author SHA1 Message Date
mika kuns
2a8cd97d02 fix(installer): expand ~ in UiDbPath
All checks were successful
Release / release (push) Successful in 29s
2026-04-17 14:33:30 +02:00
mika kuns
09e8b1f10b fix(ui): init editor TCS before dialog can complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:32:46 +02:00
mika kuns
92d8d902df fix(ui): reset stale worktree state on TaskDetail reload 2026-04-17 14:31:24 +02:00
mika kuns
aa1008dcff fix(ui): capture CurrentListId before await in AddTask 2026-04-17 14:30:35 +02:00
mika kuns
5f3d41e1f6 fix(installer): make user-data deletion on uninstall opt-in
Add bool removeAppData parameter (default false) to UninstallRunner.RunAsync,
gate ~/.todo-app deletion on it, surface a checkbox in SettingsWindow, and
update the confirmation message to reflect whether data will be removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:29:35 +02:00
mika kuns
7d48f34b15 fix(installer): rollback-safe extract with .bak stash 2026-04-17 14:27:45 +02:00
mika kuns
51a1bbe6b8 fix(installer): move service start out of RegisterServiceStep 2026-04-17 14:26:34 +02:00
mika kuns
ad7c9facaf fix(worker): escape newline/tab in CLI args 2026-04-17 14:25:15 +02:00
mika kuns
11a4376da5 fix(worker): guard against same task in queue and override slot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:23:08 +02:00
mika kuns
f10ad69863 fix(installer): reject CurrentUser service account without password 2026-04-17 14:20:09 +02:00
mika kuns
dc4571a338 fix(ui): swallow DB errors in TaskListViewModel.OnTaskUpdated 2026-04-17 14:19:11 +02:00
mika kuns
4fb6ba6be8 fix(worker): emit RunCreated after run row exists
Remove premature RunCreated broadcast from WorkerHub.RunNow and the
duplicate calls in RunAsync retry block and ContinueAsync. RunOnceAsync
now owns the broadcast for every run, fired immediately after the row
insert so the UI never receives an event for a non-existent row.
2026-04-17 14:17:00 +02:00
mika kuns
3423919655 fix: resolve critical bugs and improve reliability across worker, data, UI
- Fix worker using wrong DB by defaulting to CurrentUser service account
  and expanding ~ to absolute paths at install time
- Fix DbContext disposed before fire-and-forget by passing taskId instead
  of TaskEntity into RunInSlotAsync, which creates its own context
- Fix ActiveTaskDto property casing mismatch between hub and client
- Move WAL mode PRAGMA before migrations to prevent concurrent lock issues
- Replace FirstAsync with FirstOrDefaultAsync + null guards in tag operations
- Add delete confirmation flow for lists
- Log fire-and-forget exceptions instead of swallowing them
- Broadcast RunCreated event from WorkerHub.RunNow
- Add IDisposable to MainWindowViewModel for event handler cleanup
- Preserve subtask CreatedAt on updates instead of overwriting
- Replace bare catch blocks with Debug.WriteLine logging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:12:59 +02:00
mika kuns
fca2bdb596 Merge remote-tracking branch 'origin/main' 2026-04-16 12:26:50 +02:00
mika kuns
721f0cd903 Merge branch 'feat/subtask-tree-view' 2026-04-16 12:17:46 +02:00
mika kuns
32bb52875f feat(ui): add subtask tree view with expand/collapse in task list
Tasks with subtasks show a chevron for inline expand/collapse.
Subtask checkboxes toggle completion state directly. Also sets
Windows AppUserModelID for proper taskbar identity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:16:22 +02:00
mika kuns
4f25c3dd40 docs: add subtask tree view design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 10:18:04 +02:00
33fedc7e26 Merge pull request 'feat/efcore-migration' (#3) from feat/efcore-migration into main
All checks were successful
Release / release (push) Successful in 30s
Reviewed-on: #3
2026-04-16 07:38:59 +00:00
mika kuns
4ca48044db chore: add design-time.db to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:10:53 +02:00
mika kuns
611454df1e fix(data): address code review findings
- Fix sort order regression in GetByListIdAsync (was descending, should be ascending)
- Restore WAL mode pragma (was silently dropped in EF migration)
- Add existing-DB compatibility shim in MigrateAndConfigure (baselines InitialCreate
  migration for databases created by the old schema.sql)
- Remove dead AddDbContext/AddScoped registrations from Worker (only IDbContextFactory
  is used by singleton consumers)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:10:35 +02:00
mika kuns
8d61b05179 docs: update CLAUDE.md files for EF Core migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:03:15 +02:00
mika kuns
7d0ca45a60 refactor(installer): switch InitDatabaseStep to EF Core migrations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:01:50 +02:00
mika kuns
36484ed45a feat(worker,ui): wire EF Core into DI and update all consumers to IDbContextFactory
Worker and App Program.cs: replace SqliteConnectionFactory+SchemaInitializer
with AddDbContextFactory<ClaudeDoDbContext> + Database.Migrate(). Repos
changed from AddSingleton to AddScoped.

All singleton services (QueueService, StaleTaskRecovery, WorktreeManager,
TaskRunner) and singleton ViewModels (MainWindowViewModel, TaskDetailViewModel,
TaskListViewModel, TaskEditorViewModel) now take IDbContextFactory<ClaudeDoDbContext>
and create short-lived contexts per operation.

Test infrastructure: DbFixture now uses EF migrations instead of SchemaInitializer;
all test classes create contexts via DbFixture.CreateContext().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:59:24 +02:00
mika kuns
b7be52a623 chore(data): remove raw ADO.NET infrastructure, add EF migration and design-time factory
Delete SqliteConnectionFactory, SchemaInitializer, and schema.sql.
Fix ValueConverter lambdas in entity configurations (no throw-expressions
in expression trees). Add IDesignTimeDbContextFactory for dotnet-ef tooling.
Generate InitialCreate migration with seed data for agent/manual tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:59:06 +02:00
mika kuns
34ca1b018f feat(data): rewrite all repositories to use EF Core ClaudeDoDbContext
Replace raw ADO.NET implementations with EF Core LINQ queries and
ExecuteUpdate/ExecuteDelete for bulk operations. TaskRepository preserves
FlipAllRunningToFailedAsync(reason) signature and keeps raw SQL for the
atomic queue claim (UPDATE...RETURNING). GetByListAsync alias kept for
backwards compat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:58:57 +02:00
mika kuns
51a5dcbb73 feat(data): add ClaudeDoDbContext with Fluent API configurations 2026-04-16 08:37:37 +02:00
mika kuns
f8f13865d2 feat(data): add navigation properties to all entity models 2026-04-16 08:36:22 +02:00
mika kuns
a064865417 chore(data): swap Microsoft.Data.Sqlite for EF Core packages 2026-04-16 08:35:40 +02:00
mika kuns
9236ca6d45 docs(data): add EF Core migration implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:32:18 +02:00
mika kuns
9e1f1370bb docs(data): add EF Core migration design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:20:33 +02:00
71 changed files with 4579 additions and 1251 deletions

1
.gitignore vendored
View File

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

View File

@@ -15,7 +15,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
## Tech Stack
- .NET 8.0, Avalonia 12.0.0 (Fluent theme)
- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM
- SQLite (WAL mode) via Entity Framework Core (Microsoft.EntityFrameworkCore.Sqlite)
- SignalR for real-time IPC
- CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`)
- Git worktrees for task isolation
@@ -27,12 +27,14 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- Worker config: `~/.todo-app/worker.config.json`
- Logs: `~/.todo-app/logs/`
- Worktrees: configured per worker (sibling or central strategy)
- Schema: `schema/schema.sql` (embedded resource in ClaudeDo.Data)
## Conventions
- Repository pattern — each entity has its own async repository
- All data operations are async with CancellationToken support
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
- Task status flow: Manual | Queued -> Running -> Done | Failed
- Worktree state flow: Active -> Merged | Discarded | Kept
- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,253 @@
# EF Core Migration Design
Replace the raw ADO.NET / Microsoft.Data.Sqlite data layer with Entity Framework Core and LINQ queries.
## Motivation
- Developer ergonomics: raw SQL is tedious to write and maintain; LINQ enables faster iteration.
- Maintainability: the ad-hoc migration approach (ALTER TABLE with error-code catching) and manual DBNull/enum mapping are a liability as the schema grows. EF Core provides proper migration versioning, value converters, and change tracking.
## Decision Summary
| Decision | Choice |
|---|---|
| Approach | Big bang — rewrite all 6 repositories at once |
| Migration strategy | Fresh start — EF Core owns the schema, drop schema.sql |
| DbContext sharing | Single shared `ClaudeDoDbContext` in ClaudeDo.Data |
| Configuration style | Fluent API only, clean POCO models |
| Atomic queue claim | Kept as `FromSqlRaw` — not expressible in LINQ |
---
## 1. DbContext and Entity Configuration
### ClaudeDoDbContext
A single `ClaudeDoDbContext` in `ClaudeDo.Data` with DbSets for all entities:
```csharp
public class ClaudeDoDbContext : DbContext
{
public DbSet<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>();
}
```
### Entity-to-Table Mapping
| Entity | Table | Key | Notes |
|---|---|---|---|
| `TaskEntity` | `tasks` | `Id` (TEXT) | Nav to List, Tags, Worktree, Runs, Subtasks |
| `ListEntity` | `lists` | `Id` (TEXT) | Nav to Tasks, Tags, Config |
| `TagEntity` | `tags` | `Id` (INTEGER auto) | Nav to Lists, Tasks (both M:N) |
| `ListConfigEntity` | `list_config` | `ListId` (TEXT) | 1:1 owned by List |
| `WorktreeEntity` | `worktrees` | `TaskId` (TEXT) | 1:1 owned by Task |
| `TaskRunEntity` | `task_runs` | `Id` (TEXT) | FK to Task |
| `SubtaskEntity` | `subtasks` | `Id` (TEXT) | FK to Task |
### Navigation Properties Added to Models
```csharp
// TaskEntity gains:
public ListEntity List { get; set; }
public WorktreeEntity? Worktree { get; set; }
public ICollection<TagEntity> Tags { get; set; }
public ICollection<TaskRunEntity> Runs { get; set; }
public ICollection<SubtaskEntity> Subtasks { get; set; }
// ListEntity gains:
public ListConfigEntity? Config { get; set; }
public ICollection<TaskEntity> Tasks { get; set; }
public ICollection<TagEntity> Tags { get; set; }
// TagEntity gains:
public ICollection<ListEntity> Lists { get; set; }
public ICollection<TaskEntity> Tasks { get; set; }
```
### Enum Handling
EF Core `ValueConverter<TEnum, string>` for `TaskStatus` and `WorktreeState`, storing the same lowercase strings (`"manual"`, `"active"`, etc.) for database compatibility. The `ToDb`/`FromDb` methods in repositories are removed.
### Junction Tables
`list_tags` and `task_tags` are configured as implicit join tables via `.UsingEntity()` in Fluent API — no explicit junction entity classes needed.
### Fluent Configuration
Each entity gets its own `IEntityTypeConfiguration<T>` class in a `Configuration/` folder within `ClaudeDo.Data`.
---
## 2. Migration Strategy
### Fresh Start
- `schema.sql` and `SchemaInitializer` are deleted.
- An initial EF Core migration (`InitialCreate`) is generated from the DbContext model, producing the full schema (all 8 tables, indexes, foreign keys, check constraints).
- EF's `__EFMigrationsHistory` table tracks applied migrations.
### Startup
Both App and Worker call `context.Database.Migrate()` at startup instead of `SchemaInitializer.Apply()`. This is idempotent.
### Existing Database Compatibility
For users who already have a database created by `schema.sql`, the initial migration must handle the schema already existing. On startup, if the `lists` table exists but `__EFMigrationsHistory` does not, insert the initial migration record into `__EFMigrationsHistory` so EF skips it.
### Seed Data
The `"agent"` and `"manual"` tags move into `OnModelCreating` via `HasData()`:
```csharp
modelBuilder.Entity<TagEntity>().HasData(
new TagEntity { Id = 1, Name = "agent" },
new TagEntity { Id = 2, Name = "manual" });
```
### Ad-hoc Migrations Removed
The 3 manual `ALTER TABLE` statements (model, system_prompt, agent_path on tasks) become part of the initial migration since they're already in the model. The manual `ApplyMigrations()` method is deleted.
---
## 3. Repository Rewrite
All 6 repositories are rewritten to use `ClaudeDoDbContext` and LINQ.
### Per-Repository Changes
| Repository | After EF Core |
|---|---|
| `TagRepository` | LINQ queries. `GetOrCreateAsync` uses `FirstOrDefaultAsync` + `Add` + `SaveChangesAsync`. Static `SqliteConnection` overload removed. |
| `SubtaskRepository` | Straightforward LINQ CRUD, `.OrderBy(s => s.OrderNum)`. |
| `WorktreeRepository` | LINQ CRUD. State update becomes property set + `SaveChangesAsync`. |
| `ListRepository` | LINQ CRUD. Tag management via `.Tags` navigation property. Config upsert via `List.Config` navigation. |
| `TaskRunRepository` | LINQ CRUD. Latest = `.OrderByDescending(r => r.RunNumber).FirstOrDefaultAsync()`. |
| `TaskRepository` | See special cases below. |
### TaskRepository Special Cases
**Atomic queue claim** (`GetNextQueuedAgentTaskAsync`): kept as `FromSqlRaw` / `ExecuteSqlRawAsync`. The `UPDATE ... WHERE id = (SELECT ...) RETURNING` is not expressible in LINQ and the atomicity guarantee matters.
**Effective tags** (`GetEffectiveTagsAsync`): LINQ via navigation properties:
```csharp
var taskTags = context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags);
var listTags = context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.List.Tags);
return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
```
**FlipAllRunningToFailed**: EF Core 7+ bulk update:
```csharp
await context.Tasks
.Where(t => t.Status == TaskStatus.Running)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Failed), ct);
```
**Status transitions** (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`): property updates + `SaveChangesAsync`.
### Removed Code
- `SqliteConnectionFactory.cs`
- `SchemaInitializer.cs`
- `schema/schema.sql`
- All `ToDb`/`FromDb` enum mapping methods
- All manual `DBNull.Value` handling
- `BindTask` helper methods
---
## 4. Package Changes and DI Registration
### ClaudeDo.Data.csproj
- Remove: `Microsoft.Data.Sqlite`
- Remove: embedded resource for `schema.sql`
- Add: `Microsoft.EntityFrameworkCore.Sqlite`
- Add: `Microsoft.EntityFrameworkCore.Design` (`PrivateAssets="all"`)
### ClaudeDo.Worker.Tests.csproj
- Remove: `Microsoft.Data.Sqlite`
- Add: `Microsoft.EntityFrameworkCore.Sqlite`
### App DI (Program.cs)
```csharp
// Replace SqliteConnectionFactory + singleton repos with:
sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={dbPath}"));
sc.AddScoped<ClaudeDoDbContext>(sp =>
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
sc.AddScoped<ListRepository>();
sc.AddScoped<TaskRepository>();
sc.AddScoped<SubtaskRepository>();
sc.AddScoped<TagRepository>();
sc.AddScoped<WorktreeRepository>();
sc.AddScoped<TaskRunRepository>();
// Migrate at startup:
using var initScope = services.CreateScope();
initScope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>().Database.Migrate();
```
ViewModels are singletons that currently take repositories as constructor parameters. Since repositories become scoped, ViewModels switch to taking `IDbContextFactory<ClaudeDoDbContext>` and create a fresh context (+ repositories) per operation. Each ViewModel method that touches data does: `using var context = _factory.CreateDbContext();` then constructs or resolves the needed repository with that context. This mirrors the current connection-per-call pattern.
### Worker DI (Program.cs)
```csharp
builder.Services.AddDbContext<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={cfg.DbPath}"));
builder.Services.AddScoped<ListRepository>();
builder.Services.AddScoped<TaskRepository>();
builder.Services.AddScoped<SubtaskRepository>();
builder.Services.AddScoped<TagRepository>();
builder.Services.AddScoped<WorktreeRepository>();
builder.Services.AddScoped<TaskRunRepository>();
// Migrate at startup after build:
using var scope = app.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>().Database.Migrate();
```
Worker has request scopes via SignalR hub invocations, so scoped registration works naturally.
---
## 5. Test Infrastructure
### DbFixture
`DbFixture` is rewritten as an EF Core fixture:
- Creates a temp SQLite file per test class.
- Builds `DbContextOptions<ClaudeDoDbContext>` with `UseSqlite`.
- Calls `context.Database.Migrate()` to apply the schema (also tests that migrations work).
- Exposes a `CreateContext()` method so each test gets a fresh context instance (avoids change-tracker bleed).
Tests construct repositories by passing in a fresh context from the fixture.
No mocking — tests keep hitting real SQLite, same philosophy as today.
---
## 6. Risk and Mitigation
| Risk | Mitigation |
|---|---|
| Big-bang rewrite touches nearly every file in ClaudeDo.Data | Existing tests are the safety net — all must pass after migration |
| Existing databases with schema from schema.sql | Compatibility shim: detect existing tables, mark initial migration as applied |
| Atomic queue claim semantics change | Kept as raw SQL via `FromSqlRaw` |
| Scoped lifetime vs. singleton ViewModels | `IDbContextFactory` provides on-demand contexts |
| EF change tracker overhead vs. raw ADO.NET | Negligible for this workload size; use `AsNoTracking()` for read-only queries |

View File

@@ -0,0 +1,118 @@
# Subtask Tree View in Task List
## Problem
Subtasks are invisible in the task list — users only see them after opening the detail pane or editor modal. This makes it hard to get an overview of task progress without clicking into each task individually.
## Solution
Show subtasks indented below their parent task in the task list, with expand/collapse. Tasks start collapsed with a visual indicator when subtasks exist.
## Scope
Pure UI/ViewModel change. No data model changes, no new migrations, no repository schema changes.
## Design
### ViewModel Changes
**TaskItemViewModel** — add:
- `ObservableCollection<SubtaskItemViewModel> Subtasks` — populated on first expand
- `bool IsExpanded` — observable, default `false`; toggles subtask visibility
- `bool HasSubtasks` — observable, set during initial load from a count query
- `int SubtaskCount` — observable, used for the indicator
- `ToggleExpandedCommand` — flips `IsExpanded`; on first expand, loads subtasks from `SubtaskRepository.GetByTaskIdAsync`
- `ToggleSubtaskDoneCommand(string subtaskId)` — toggles a subtask's `Completed` and persists via `SubtaskRepository.UpdateAsync`
Constructor gains `SubtaskRepository` and initial `subtaskCount` parameter.
**TaskListViewModel.LoadAsync** — after fetching tasks, run a single batch query to get subtask counts per task. Pass counts into each `TaskItemViewModel`. This avoids N+1 queries on load.
**TaskListViewModel.RefreshSingleAsync** — if the refreshed task's `IsExpanded` is true, also reload its subtasks from DB and update the collection.
### Repository Changes
**SubtaskRepository** — add one method:
```csharp
Task<Dictionary<string, int>> GetCountsByTaskIdsAsync(IEnumerable<string> taskIds, CancellationToken ct = default)
```
Single query: `SELECT task_id, COUNT(*) FROM subtasks WHERE task_id IN (...) GROUP BY task_id`. Returns a map of taskId -> count. Tasks with no subtasks won't appear in the result (count defaults to 0).
### XAML Changes
**TaskListView.axaml** — the `DataTemplate` for `TaskItemViewModel` becomes a 2-row grid:
```
Row 0: [ExpandChevron] [StatusCircle] [Title + Tags/Status subtitle]
Row 1: [SubtaskItemsControl, margin-left ~40px, visible when IsExpanded]
```
**Row 0 — Expand chevron:**
- Column 0 gets a small chevron button (12x12 `Path` data) before the status circle
- Right-pointing when collapsed, down-pointing when expanded
- Bound to `ToggleExpandedCommand`
- Only visible when `HasSubtasks` is true (via `IsVisible` binding)
- When `HasSubtasks` is false, the space is empty but reserved (fixed-width column) so all titles align
**Row 1 — Subtask list:**
- `ItemsControl` bound to `Subtasks`
- `IsVisible` bound to `IsExpanded`
- Left margin ~40px for visual indentation
- Each subtask item: `CheckBox` (bound to `Completed`) + `TextBlock` (bound to `Title`)
- Subtask row has its own context menu flyout with "Edit Task" (opens parent task's editor modal via `EditTaskCommand` on root `TaskListViewModel`)
- Checkbox toggle calls `ToggleSubtaskDoneCommand` on the parent `TaskItemViewModel`
**Column layout change:** The existing 2-column `Grid` (`Auto, *`) gets a third column prepended: `Auto, Auto, *`. The chevron goes in column 0, status circle in column 1, title stack in column 2. Row 1 spans all 3 columns.
### Subtask Checkbox Interaction
When a subtask checkbox is toggled in the list:
1. Update the `SubtaskItemViewModel.Completed` property
2. Call `SubtaskRepository.UpdateAsync` with the updated entity (same auto-save pattern as `TaskDetailView`)
3. No need to refresh the parent task — subtask completion doesn't affect task status
### Subtask Context Menu
Right-click on a subtask row shows:
- "Edit Task" — opens the parent task's editor modal (same flow as `EditTaskCommand`)
This reuses the existing editor which already has full subtask editing (add/remove/reorder/rename).
### Real-time Updates
When `RefreshSingleAsync` fires (via SignalR `TaskUpdatedEvent`):
1. Reload subtask count, update `HasSubtasks` and `SubtaskCount`
2. If `IsExpanded`, reload subtask list from DB and reconcile with the observable collection
### Detail Pane Sync
When the user edits subtasks in `TaskDetailView` (auto-save) or `TaskEditorView` (batch-save), the list view's subtask state may become stale. Two options:
**Chosen approach:** The detail pane and editor already trigger `TaskUpdatedEvent` (or the editor's save path calls `RefreshSingleAsync` via `SelectedTask.Refresh`). Extend `Refresh` on `TaskItemViewModel` to also reload subtasks if expanded, and update `HasSubtasks`/`SubtaskCount`.
### Visual Style
- Chevron: 10x10 path, `TextDimBrush` color, no background, cursor=Hand
- Subtask rows: smaller font (12px), `TextDimBrush` for unchecked title, strikethrough + dimmed for completed
- Subtask checkbox: standard Avalonia `CheckBox` (no custom circular border), small size
- Subtask row vertical padding: 2px (compact)
- Indent: 40px left margin on the subtask `ItemsControl`
## Files to Modify
1. `src/ClaudeDo.Data/Repositories/SubtaskRepository.cs` — add `GetCountsByTaskIdsAsync`
2. `src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs` — add subtask collection, expand/collapse, toggle done
3. `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — batch-load counts, pass SubtaskRepository, extend refresh
4. `src/ClaudeDo.Ui/Views/TaskListView.axaml` — restructure item template with chevron + nested ItemsControl
5. `src/ClaudeDo.Ui/Views/TaskListView.axaml.cs` — handle subtask context menu pointer-pressed if needed
6. `src/ClaudeDo.App/Program.cs` — pass SubtaskRepository to TaskListViewModel (if not already available via DI)
## Out of Scope
- Drag-to-reorder subtasks in the list view
- Add subtask directly from the list view
- Subtask progress indicator (e.g., "2/5 done") on collapsed tasks
- Recursive task nesting (tasks containing tasks)

View File

@@ -1,100 +0,0 @@
-- ClaudeDo SQLite schema (single source of truth, 3NF)
-- Applied by Worker on first startup. WAL mode is set via PRAGMA after open.
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
working_dir TEXT NULL,
default_commit_type TEXT NOT NULL DEFAULT 'chore'
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NULL,
status TEXT NOT NULL CHECK (status IN ('manual','queued','running','done','failed')),
scheduled_for TIMESTAMP NULL,
result TEXT NULL,
log_path TEXT NULL,
created_at TIMESTAMP NOT NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
commit_type TEXT NOT NULL DEFAULT 'chore'
);
CREATE INDEX IF NOT EXISTS idx_tasks_list_id ON tasks(list_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS list_tags (
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (list_id, tag_id)
);
CREATE TABLE IF NOT EXISTS task_tags (
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, tag_id)
);
CREATE TABLE IF NOT EXISTS list_config (
list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE,
model TEXT NULL,
system_prompt TEXT NULL,
agent_path TEXT NULL
);
CREATE TABLE IF NOT EXISTS worktrees (
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
path TEXT NOT NULL,
branch_name TEXT NOT NULL,
base_commit TEXT NOT NULL,
head_commit TEXT NULL,
diff_stat TEXT NULL,
state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active','merged','discarded','kept')),
created_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS task_runs (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
run_number INTEGER NOT NULL,
session_id TEXT NULL,
is_retry INTEGER NOT NULL DEFAULT 0,
prompt TEXT NOT NULL,
result_markdown TEXT NULL,
structured_output TEXT NULL,
error_markdown TEXT NULL,
exit_code INTEGER NULL,
turn_count INTEGER NULL,
tokens_in INTEGER NULL,
tokens_out INTEGER NULL,
log_path TEXT NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
CREATE TABLE IF NOT EXISTS subtasks (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
order_num INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_subtasks_task_id ON subtasks(task_id);
-- Seed: minimal tag set (ignored if already present)
INSERT OR IGNORE INTO tags (name) VALUES ('agent');
INSERT OR IGNORE INTO tags (name) VALUES ('manual');

View File

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

View File

@@ -11,7 +11,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Repositories
All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each method opens its own connection — no Unit of Work.
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim.
- **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync`
- **ListRepository** — CRUD, tag junction management
@@ -20,8 +20,8 @@ All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each met
## Infrastructure
- **SqliteConnectionFactory** — creates connections, applies WAL mode once, enforces foreign keys via PRAGMA
- **SchemaInitializer** — applies embedded `schema/schema.sql` idempotently (IF NOT EXISTS, INSERT OR IGNORE)
- **ClaudeDoDbContext** — EF Core DbContext; configured with WAL mode and foreign keys via `UseSqlite` options
- **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service)
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
@@ -31,11 +31,11 @@ All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each met
## Schema
6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. See `schema/schema.sql`. Seed data: tags "agent" and "manual".
6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual".
## Conventions
- Enum <-> string mapping via explicit `ToDb()`/`FromDb()` static methods on each enum
- Enum <-> string mapping via EF Core `ValueConverter` (configured in `IEntityTypeConfiguration<T>`)
- Entity configurations live in the `Configuration/` folder
- Primary keys are `init`-only strings (GUIDs assigned at creation)
- Nullable fields use `DBNull.Value` checks
- All methods are async with CancellationToken where applicable

View File

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

View File

@@ -0,0 +1,77 @@
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)
{
var conn = db.Database.GetDbConnection();
try
{
conn.Open();
// Set WAL FIRST, before migrations — prevents write-lock contention
// when UI and Worker start simultaneously.
using (var walCmd = conn.CreateCommand())
{
walCmd.CommandText = "PRAGMA journal_mode=wal;";
walCmd.ExecuteNonQuery();
}
// If the 'lists' table exists but __EFMigrationsHistory does not,
// this is a pre-EF database. Baseline the InitialCreate migration.
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)
{
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();
}
}
}
finally
{
conn.Close();
}
db.Database.Migrate();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Configuration;
public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
{
private static string StatusToString(TaskStatus v)
=> v == TaskStatus.Manual ? "manual"
: v == TaskStatus.Queued ? "queued"
: v == TaskStatus.Running ? "running"
: v == TaskStatus.Done ? "done"
: v == TaskStatus.Failed ? "failed"
: throw new ArgumentOutOfRangeException(nameof(v));
private static TaskStatus StatusFromString(string v)
=> v == "manual" ? TaskStatus.Manual
: v == "queued" ? TaskStatus.Queued
: v == "running" ? TaskStatus.Running
: v == "done" ? TaskStatus.Done
: v == "failed" ? TaskStatus.Failed
: throw new ArgumentOutOfRangeException(nameof(v));
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
new(v => StatusToString(v), v => StatusFromString(v));
public void Configure(EntityTypeBuilder<TaskEntity> builder)
{
builder.ToTable("tasks");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id");
builder.Property(t => t.ListId).HasColumnName("list_id").IsRequired();
builder.Property(t => t.Title).HasColumnName("title").IsRequired();
builder.Property(t => t.Description).HasColumnName("description");
builder.Property(t => t.Status).HasColumnName("status").IsRequired()
.HasConversion(StatusConverter);
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
builder.Property(t => t.Result).HasColumnName("result");
builder.Property(t => t.LogPath).HasColumnName("log_path");
builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(t => t.StartedAt).HasColumnName("started_at");
builder.Property(t => t.FinishedAt).HasColumnName("finished_at");
builder.Property(t => t.CommitType).HasColumnName("commit_type").IsRequired().HasDefaultValue("chore");
builder.Property(t => t.Model).HasColumnName("model");
builder.Property(t => t.SystemPrompt).HasColumnName("system_prompt");
builder.Property(t => t.AgentPath).HasColumnName("agent_path");
builder.HasOne(t => t.List)
.WithMany(l => l.Tasks)
.HasForeignKey(t => t.ListId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(t => t.Worktree)
.WithOne(w => w.Task)
.HasForeignKey<WorktreeEntity>(w => w.TaskId);
builder.HasMany(t => t.Tags)
.WithMany(tag => tag.Tasks)
.UsingEntity("task_tags",
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
r => r.HasOne(typeof(TaskEntity)).WithMany().HasForeignKey("task_id").OnDelete(DeleteBehavior.Cascade),
j =>
{
j.HasKey("task_id", "tag_id");
j.ToTable("task_tags");
});
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
}
}

View File

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

View File

@@ -0,0 +1,43 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace ClaudeDo.Data.Configuration;
public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEntity>
{
private static string StateToString(WorktreeState v)
=> v == WorktreeState.Active ? "active"
: v == WorktreeState.Merged ? "merged"
: v == WorktreeState.Discarded ? "discarded"
: v == WorktreeState.Kept ? "kept"
: throw new ArgumentOutOfRangeException(nameof(v));
private static WorktreeState StateFromString(string v)
=> v == "active" ? WorktreeState.Active
: v == "merged" ? WorktreeState.Merged
: v == "discarded" ? WorktreeState.Discarded
: v == "kept" ? WorktreeState.Kept
: throw new ArgumentOutOfRangeException(nameof(v));
private static readonly ValueConverter<WorktreeState, string> StateConverter =
new(v => StateToString(v), v => StateFromString(v));
public void Configure(EntityTypeBuilder<WorktreeEntity> builder)
{
builder.ToTable("worktrees");
builder.HasKey(w => w.TaskId);
builder.Property(w => w.TaskId).HasColumnName("task_id");
builder.Property(w => w.Path).HasColumnName("path").IsRequired();
builder.Property(w => w.BranchName).HasColumnName("branch_name").IsRequired();
builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired();
builder.Property(w => w.HeadCommit).HasColumnName("head_commit");
builder.Property(w => w.DiffStat).HasColumnName("diff_stat");
builder.Property(w => w.State).HasColumnName("state").IsRequired()
.HasDefaultValue(WorktreeState.Active)
.HasConversion(StateConverter);
builder.Property(w => w.CreatedAt).HasColumnName("created_at").IsRequired();
}
}

View File

@@ -0,0 +1,298 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "lists",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
working_dir = table.Column<string>(type: "TEXT", nullable: true),
default_commit_type = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "chore")
},
constraints: table =>
{
table.PrimaryKey("PK_lists", x => x.id);
});
migrationBuilder.CreateTable(
name: "tags",
columns: table => new
{
id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
name = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_tags", x => x.id);
});
migrationBuilder.CreateTable(
name: "list_config",
columns: table => new
{
list_id = table.Column<string>(type: "TEXT", nullable: false),
model = table.Column<string>(type: "TEXT", nullable: true),
system_prompt = table.Column<string>(type: "TEXT", nullable: true),
agent_path = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_list_config", x => x.list_id);
table.ForeignKey(
name: "FK_list_config_lists_list_id",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tasks",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
list_id = table.Column<string>(type: "TEXT", nullable: false),
title = table.Column<string>(type: "TEXT", nullable: false),
description = table.Column<string>(type: "TEXT", nullable: true),
status = table.Column<string>(type: "TEXT", nullable: false),
scheduled_for = table.Column<DateTime>(type: "TEXT", nullable: true),
result = table.Column<string>(type: "TEXT", nullable: true),
log_path = table.Column<string>(type: "TEXT", nullable: true),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
started_at = table.Column<DateTime>(type: "TEXT", nullable: true),
finished_at = table.Column<DateTime>(type: "TEXT", nullable: true),
commit_type = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "chore"),
model = table.Column<string>(type: "TEXT", nullable: true),
system_prompt = table.Column<string>(type: "TEXT", nullable: true),
agent_path = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_tasks", x => x.id);
table.ForeignKey(
name: "FK_tasks_lists_list_id",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "list_tags",
columns: table => new
{
list_id = table.Column<string>(type: "TEXT", nullable: false),
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_list_tags", x => new { x.list_id, x.tag_id });
table.ForeignKey(
name: "FK_list_tags_lists_list_id",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_list_tags_tags_tag_id",
column: x => x.tag_id,
principalTable: "tags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subtasks",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
task_id = table.Column<string>(type: "TEXT", nullable: false),
title = table.Column<string>(type: "TEXT", nullable: false),
completed = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
order_num = table.Column<int>(type: "INTEGER", nullable: false),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_subtasks", x => x.id);
table.ForeignKey(
name: "FK_subtasks_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "task_runs",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
task_id = table.Column<string>(type: "TEXT", nullable: false),
run_number = table.Column<int>(type: "INTEGER", nullable: false),
session_id = table.Column<string>(type: "TEXT", nullable: true),
is_retry = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
prompt = table.Column<string>(type: "TEXT", nullable: false),
result_markdown = table.Column<string>(type: "TEXT", nullable: true),
structured_output = table.Column<string>(type: "TEXT", nullable: true),
error_markdown = table.Column<string>(type: "TEXT", nullable: true),
exit_code = table.Column<int>(type: "INTEGER", nullable: true),
turn_count = table.Column<int>(type: "INTEGER", nullable: true),
tokens_in = table.Column<int>(type: "INTEGER", nullable: true),
tokens_out = table.Column<int>(type: "INTEGER", nullable: true),
log_path = table.Column<string>(type: "TEXT", nullable: true),
started_at = table.Column<DateTime>(type: "TEXT", nullable: true),
finished_at = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_task_runs", x => x.id);
table.ForeignKey(
name: "FK_task_runs_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "task_tags",
columns: table => new
{
task_id = table.Column<string>(type: "TEXT", nullable: false),
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_task_tags", x => new { x.task_id, x.tag_id });
table.ForeignKey(
name: "FK_task_tags_tags_tag_id",
column: x => x.tag_id,
principalTable: "tags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_task_tags_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "worktrees",
columns: table => new
{
task_id = table.Column<string>(type: "TEXT", nullable: false),
path = table.Column<string>(type: "TEXT", nullable: false),
branch_name = table.Column<string>(type: "TEXT", nullable: false),
base_commit = table.Column<string>(type: "TEXT", nullable: false),
head_commit = table.Column<string>(type: "TEXT", nullable: true),
diff_stat = table.Column<string>(type: "TEXT", nullable: true),
state = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "active"),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_worktrees", x => x.task_id);
table.ForeignKey(
name: "FK_worktrees_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "tags",
columns: new[] { "id", "name" },
values: new object[,]
{
{ 1L, "agent" },
{ 2L, "manual" }
});
migrationBuilder.CreateIndex(
name: "IX_list_tags_tag_id",
table: "list_tags",
column: "tag_id");
migrationBuilder.CreateIndex(
name: "idx_subtasks_task_id",
table: "subtasks",
column: "task_id");
migrationBuilder.CreateIndex(
name: "IX_tags_name",
table: "tags",
column: "name",
unique: true);
migrationBuilder.CreateIndex(
name: "idx_task_runs_task_id",
table: "task_runs",
column: "task_id");
migrationBuilder.CreateIndex(
name: "IX_task_tags_tag_id",
table: "task_tags",
column: "tag_id");
migrationBuilder.CreateIndex(
name: "idx_tasks_list_id",
table: "tasks",
column: "list_id");
migrationBuilder.CreateIndex(
name: "idx_tasks_status",
table: "tasks",
column: "status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "list_config");
migrationBuilder.DropTable(
name: "list_tags");
migrationBuilder.DropTable(
name: "subtasks");
migrationBuilder.DropTable(
name: "task_runs");
migrationBuilder.DropTable(
name: "task_tags");
migrationBuilder.DropTable(
name: "worktrees");
migrationBuilder.DropTable(
name: "tags");
migrationBuilder.DropTable(
name: "tasks");
migrationBuilder.DropTable(
name: "lists");
}
}
}

View File

@@ -0,0 +1,479 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
partial class ClaudeDoDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -6,4 +6,7 @@ public sealed class ListConfigEntity
public string? Model { get; set; }
public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; }
// Navigation property
public ListEntity List { get; set; } = null!;
}

View File

@@ -7,4 +7,9 @@ public sealed class ListEntity
public required DateTime CreatedAt { get; init; }
public string? WorkingDir { get; set; }
public string DefaultCommitType { get; set; } = "chore";
// Navigation properties
public ListConfigEntity? Config { get; set; }
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
}

View File

@@ -8,4 +8,7 @@ public sealed class SubtaskEntity
public bool Completed { get; set; }
public int OrderNum { get; set; }
public required DateTime CreatedAt { get; init; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
}

View File

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

View File

@@ -26,4 +26,11 @@ public sealed class TaskEntity
public string? Model { get; set; }
public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; }
// Navigation properties
public ListEntity List { get; set; } = null!;
public WorktreeEntity? Worktree { get; set; }
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
}

View File

@@ -18,4 +18,7 @@ public sealed class TaskRunEntity
public string? LogPath { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
}

View File

@@ -18,4 +18,7 @@ public sealed class WorktreeEntity
public string? DiffStat { get; set; }
public WorktreeState State { get; set; } = WorktreeState.Active;
public required DateTime CreatedAt { get; init; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
}

View File

@@ -1,157 +1,91 @@
using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class ListRepository
{
private readonly SqliteConnectionFactory _factory;
private readonly ClaudeDoDbContext _context;
public ListRepository(SqliteConnectionFactory factory) => _factory = factory;
public ListRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO lists (id, name, created_at, working_dir, default_commit_type)
VALUES (@id, @name, @created_at, @working_dir, @default_commit_type)
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@name", entity.Name);
cmd.Parameters.AddWithValue("@created_at", entity.CreatedAt.ToString("o"));
cmd.Parameters.AddWithValue("@working_dir", (object?)entity.WorkingDir ?? DBNull.Value);
cmd.Parameters.AddWithValue("@default_commit_type", entity.DefaultCommitType);
await cmd.ExecuteNonQueryAsync(ct);
_context.Lists.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE lists SET name = @name, working_dir = @working_dir,
default_commit_type = @default_commit_type
WHERE id = @id
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@name", entity.Name);
cmd.Parameters.AddWithValue("@working_dir", (object?)entity.WorkingDir ?? DBNull.Value);
cmd.Parameters.AddWithValue("@default_commit_type", entity.DefaultCommitType);
await cmd.ExecuteNonQueryAsync(ct);
_context.Lists.Update(entity);
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(string listId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM lists WHERE id = @id";
cmd.Parameters.AddWithValue("@id", listId);
await cmd.ExecuteNonQueryAsync(ct);
await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct);
}
public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name, created_at, working_dir, default_commit_type FROM lists WHERE id = @id";
cmd.Parameters.AddWithValue("@id", listId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadList(reader);
return await _context.Lists.FirstOrDefaultAsync(l => l.Id == listId, ct);
}
public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name, created_at, working_dir, default_commit_type FROM lists ORDER BY created_at";
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<ListEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadList(reader));
return result;
return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct);
}
public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT t.id, t.name FROM tags t
JOIN list_tags lt ON lt.tag_id = t.id
WHERE lt.list_id = @list_id
""";
cmd.Parameters.AddWithValue("@list_id", listId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TagEntity>();
while (await reader.ReadAsync(ct))
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return result;
return await _context.Lists
.Where(l => l.Id == listId)
.SelectMany(l => l.Tags)
.ToListAsync(ct);
}
public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT OR IGNORE INTO list_tags (list_id, tag_id) VALUES (@list_id, @tag_id)";
cmd.Parameters.AddWithValue("@list_id", listId);
cmd.Parameters.AddWithValue("@tag_id", tagId);
await cmd.ExecuteNonQueryAsync(ct);
var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
if (list is null) return;
var tag = await _context.Tags.FindAsync([tagId], ct);
if (tag is not null && !list.Tags.Any(t => t.Id == tagId))
{
list.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
}
}
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM list_tags WHERE list_id = @list_id AND tag_id = @tag_id";
cmd.Parameters.AddWithValue("@list_id", listId);
cmd.Parameters.AddWithValue("@tag_id", tagId);
await cmd.ExecuteNonQueryAsync(ct);
var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
if (list is null) return;
var tag = list.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null)
{
list.Tags.Remove(tag);
await _context.SaveChangesAsync(ct);
}
}
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT list_id, model, system_prompt, agent_path FROM list_config WHERE list_id = @list_id";
cmd.Parameters.AddWithValue("@list_id", listId);
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
}
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return new ListConfigEntity
public async Task SetConfigAsync(ListConfigEntity config, CancellationToken ct = default)
{
var existing = await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == config.ListId, ct);
if (existing is null)
{
ListId = reader.GetString(0),
Model = reader.IsDBNull(1) ? null : reader.GetString(1),
SystemPrompt = reader.IsDBNull(2) ? null : reader.GetString(2),
AgentPath = reader.IsDBNull(3) ? null : reader.GetString(3),
};
_context.ListConfigs.Add(config);
}
else
{
existing.Model = config.Model;
existing.SystemPrompt = config.SystemPrompt;
existing.AgentPath = config.AgentPath;
}
await _context.SaveChangesAsync(ct);
}
public async Task SetConfigAsync(ListConfigEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO list_config (list_id, model, system_prompt, agent_path)
VALUES (@list_id, @model, @system_prompt, @agent_path)
""";
cmd.Parameters.AddWithValue("@list_id", entity.ListId);
cmd.Parameters.AddWithValue("@model", (object?)entity.Model ?? DBNull.Value);
cmd.Parameters.AddWithValue("@system_prompt", (object?)entity.SystemPrompt ?? DBNull.Value);
cmd.Parameters.AddWithValue("@agent_path", (object?)entity.AgentPath ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
private static ListEntity ReadList(SqliteDataReader reader) => new()
{
Id = reader.GetString(0),
Name = reader.GetString(1),
CreatedAt = DateTime.Parse(reader.GetString(2)),
WorkingDir = reader.IsDBNull(3) ? null : reader.GetString(3),
DefaultCommitType = reader.GetString(4),
};
}

View File

@@ -1,81 +1,41 @@
using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class SubtaskRepository
{
private readonly SqliteConnectionFactory _factory;
private readonly ClaudeDoDbContext _context;
public SubtaskRepository(SqliteConnectionFactory factory) => _factory = factory;
public async Task<List<SubtaskEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, title, completed, order_num, created_at FROM subtasks WHERE task_id = @task_id ORDER BY order_num";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<SubtaskEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadSubtask(reader));
return result;
}
public SubtaskRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO subtasks (id, task_id, title, completed, order_num, created_at)
VALUES (@id, @task_id, @title, @completed, @order_num, @created_at)
""";
BindSubtask(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
_context.Subtasks.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task<List<SubtaskEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{
return await _context.Subtasks
.Where(s => s.TaskId == taskId)
.OrderBy(s => s.OrderNum)
.ToListAsync(ct);
}
public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE subtasks SET title = @title, completed = @completed, order_num = @order_num
WHERE id = @id
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@title", entity.Title);
cmd.Parameters.AddWithValue("@completed", entity.Completed ? 1 : 0);
cmd.Parameters.AddWithValue("@order_num", entity.OrderNum);
await cmd.ExecuteNonQueryAsync(ct);
_context.Subtasks.Update(entity);
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(string id, CancellationToken ct = default)
public async Task DeleteAsync(string subtaskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM subtasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", id);
await cmd.ExecuteNonQueryAsync(ct);
await _context.Subtasks.Where(s => s.Id == subtaskId).ExecuteDeleteAsync(ct);
}
private static void BindSubtask(SqliteCommand cmd, SubtaskEntity e)
public async Task DeleteByTaskIdAsync(string taskId, CancellationToken ct = default)
{
cmd.Parameters.AddWithValue("@id", e.Id);
cmd.Parameters.AddWithValue("@task_id", e.TaskId);
cmd.Parameters.AddWithValue("@title", e.Title);
cmd.Parameters.AddWithValue("@completed", e.Completed ? 1 : 0);
cmd.Parameters.AddWithValue("@order_num", e.OrderNum);
cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
await _context.Subtasks.Where(s => s.TaskId == taskId).ExecuteDeleteAsync(ct);
}
private static SubtaskEntity ReadSubtask(SqliteDataReader r) => new()
{
Id = r.GetString(0),
TaskId = r.GetString(1),
Title = r.GetString(2),
Completed = r.GetInt64(3) != 0,
OrderNum = r.GetInt32(4),
CreatedAt = DateTime.Parse(r.GetString(5)),
};
}

View File

@@ -1,47 +1,28 @@
using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class TagRepository
{
private readonly SqliteConnectionFactory _factory;
private readonly ClaudeDoDbContext _context;
public TagRepository(SqliteConnectionFactory factory) => _factory = factory;
public TagRepository(ClaudeDoDbContext context) => _context = context;
public async Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name FROM tags ORDER BY id";
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TagEntity>();
while (await reader.ReadAsync(ct))
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return result;
return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct);
}
public async Task<long> GetOrCreateAsync(string name, CancellationToken ct = default)
{
await using var conn = _factory.Open();
return await GetOrCreateAsync(conn, name, ct);
}
public static async Task<long> GetOrCreateAsync(SqliteConnection conn, string name, CancellationToken ct = default)
{
await using var sel = conn.CreateCommand();
sel.CommandText = "SELECT id FROM tags WHERE name = @name";
sel.Parameters.AddWithValue("@name", name);
var existing = await sel.ExecuteScalarAsync(ct);
var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
if (existing is not null)
return (long)existing;
return existing.Id;
await using var ins = conn.CreateCommand();
ins.CommandText = "INSERT INTO tags (name) VALUES (@name) RETURNING id";
ins.Parameters.AddWithValue("@name", name);
return (long)(await ins.ExecuteScalarAsync(ct))!;
var tag = new TagEntity { Name = name };
_context.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
return tag.Id;
}
}

View File

@@ -1,171 +1,148 @@
using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Repositories;
public sealed class TaskRepository
{
private readonly SqliteConnectionFactory _factory;
private readonly ClaudeDoDbContext _context;
public TaskRepository(SqliteConnectionFactory factory) => _factory = factory;
#region Status mapping
private static string ToDb(TaskStatus s) => s switch
{
TaskStatus.Manual => "manual",
TaskStatus.Queued => "queued",
TaskStatus.Running => "running",
TaskStatus.Done => "done",
TaskStatus.Failed => "failed",
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
private static TaskStatus FromDb(string s) => s switch
{
"manual" => TaskStatus.Manual,
"queued" => TaskStatus.Queued,
"running" => TaskStatus.Running,
"done" => TaskStatus.Done,
"failed" => TaskStatus.Failed,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
#endregion
public TaskRepository(ClaudeDoDbContext context) => _context = context;
#region CRUD
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO tasks (id, list_id, title, description, status, scheduled_for,
result, log_path, created_at, started_at, finished_at, commit_type,
model, system_prompt, agent_path)
VALUES (@id, @list_id, @title, @description, @status, @scheduled_for,
@result, @log_path, @created_at, @started_at, @finished_at, @commit_type,
@model, @system_prompt, @agent_path)
""";
BindTask(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
_context.Tasks.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE tasks SET list_id = @list_id, title = @title, description = @description,
status = @status, scheduled_for = @scheduled_for, result = @result,
log_path = @log_path, started_at = @started_at,
finished_at = @finished_at, commit_type = @commit_type,
model = @model, system_prompt = @system_prompt, agent_path = @agent_path
WHERE id = @id
""";
BindTask(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
_context.Tasks.Update(entity);
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM tasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
await cmd.ExecuteNonQueryAsync(ct);
await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct);
}
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadTask(reader);
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
}
public async Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE list_id = @list_id ORDER BY created_at";
cmd.Parameters.AddWithValue("@list_id", listId);
return await _context.Tasks
.Where(t => t.ListId == listId)
.OrderBy(t => t.CreatedAt)
.ToListAsync(ct);
}
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TaskEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadTask(reader));
return result;
// Kept for backwards-compatibility with callers using the old name.
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
=> GetByListIdAsync(listId, ct);
#endregion
#region Status transitions
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Running)
.SetProperty(t => t.StartedAt, startedAt), ct);
}
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Done)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.Result, result), ct);
}
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Failed)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.Result, result), ct);
}
public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.LogPath, logPath), ct);
}
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
{
var resultText = "[stale] " + reason;
var now = DateTime.UtcNow;
return await _context.Tasks
.Where(t => t.Status == TaskStatus.Running)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Failed)
.SetProperty(t => t.FinishedAt, now)
.SetProperty(t => t.Result, resultText), ct);
}
#endregion
#region Tag junction
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT t.id, t.name FROM tags t
JOIN task_tags tt ON tt.tag_id = t.id
WHERE tt.task_id = @task_id
""";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TagEntity>();
while (await reader.ReadAsync(ct))
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return result;
}
#region Tags
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (@task_id, @tag_id)";
cmd.Parameters.AddWithValue("@task_id", taskId);
cmd.Parameters.AddWithValue("@tag_id", tagId);
await cmd.ExecuteNonQueryAsync(ct);
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null) return;
var tag = await _context.Tags.FindAsync([tagId], ct);
if (tag is not null && !task.Tags.Any(t => t.Id == tagId))
{
task.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
}
}
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM task_tags WHERE task_id = @task_id AND tag_id = @tag_id";
cmd.Parameters.AddWithValue("@task_id", taskId);
cmd.Parameters.AddWithValue("@tag_id", tagId);
await cmd.ExecuteNonQueryAsync(ct);
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null) return;
var tag = task.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null)
{
task.Tags.Remove(tag);
await _context.SaveChangesAsync(ct);
}
}
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
{
return await _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags)
.ToListAsync(ct);
}
public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT DISTINCT t.id, t.name FROM tags t
WHERE t.id IN (
SELECT tag_id FROM task_tags WHERE task_id = @task_id
UNION
SELECT lt.tag_id FROM list_tags lt
JOIN tasks tk ON tk.list_id = lt.list_id
WHERE tk.id = @task_id
)
""";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TagEntity>();
while (await reader.ReadAsync(ct))
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return result;
var taskTags = _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags);
var listTags = _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.List.Tags);
return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
}
#endregion
@@ -174,146 +151,38 @@ public sealed class TaskRepository
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
{
// Atomically claim the next queued agent task: the UPDATE flips its
// status to 'running' in the same statement that returns its row,
// eliminating the TOCTOU gap where two queue-loop iterations could
// both select the same queued task before either marked it running.
// The caller is responsible for populating started_at shortly after.
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE tasks
SET status = 'running'
// Atomic queue claim: UPDATE + RETURNING in one statement prevents TOCTOU races.
// Uses raw SQL because EF cannot express UPDATE...RETURNING.
// Includes both task-level and list-level "agent" tag so lists tagged "agent"
// automatically enqueue all their tasks without per-task tagging.
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — use the same format for comparison.
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
var result = await _context.Tasks.FromSqlRaw("""
UPDATE tasks SET status = 'running'
WHERE id = (
SELECT t.id
FROM tasks t
SELECT t.id FROM tasks t
WHERE t.status = 'queued'
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now)
AND EXISTS (
SELECT 1 FROM task_tags tt
JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.task_id = t.id AND tg.name = 'agent'
UNION
SELECT 1 FROM list_tags lt
JOIN tags tg ON tg.id = lt.tag_id
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
AND (
EXISTS (
SELECT 1 FROM task_tags tt
JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.task_id = t.id AND tg.name = 'agent'
)
OR EXISTS (
SELECT 1 FROM list_tags lt
JOIN tags tg ON tg.id = lt.tag_id
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
)
)
ORDER BY t.created_at ASC
LIMIT 1
)
RETURNING id, list_id, title, description, status, scheduled_for,
result, log_path, created_at, started_at, finished_at, commit_type,
model, system_prompt, agent_path
""";
cmd.Parameters.AddWithValue("@now", now.ToString("o"));
RETURNING *
""", nowStr).ToListAsync(ct);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadTask(reader);
return result.FirstOrDefault();
}
#endregion
#region Transitions
public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET log_path = @log_path WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@log_path", logPath);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'running', started_at = @started_at WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@started_at", startedAt.ToString("o"));
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'done', finished_at = @finished_at, result = @result WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o"));
cmd.Parameters.AddWithValue("@result", (object?)result ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? errorMarkdown, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'failed', finished_at = @finished_at, result = @result WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o"));
cmd.Parameters.AddWithValue("@result", (object?)errorMarkdown ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE tasks SET status = 'failed',
finished_at = @now,
result = '[stale] ' || @reason
WHERE status = 'running'
""";
cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o"));
cmd.Parameters.AddWithValue("@reason", reason);
return await cmd.ExecuteNonQueryAsync(ct);
}
#endregion
#region Helpers
private static void BindTask(SqliteCommand cmd, TaskEntity e)
{
cmd.Parameters.AddWithValue("@id", e.Id);
cmd.Parameters.AddWithValue("@list_id", e.ListId);
cmd.Parameters.AddWithValue("@title", e.Title);
cmd.Parameters.AddWithValue("@description", (object?)e.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("@status", ToDb(e.Status));
cmd.Parameters.AddWithValue("@scheduled_for", e.ScheduledFor.HasValue ? e.ScheduledFor.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@result", (object?)e.Result ?? DBNull.Value);
cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@commit_type", e.CommitType);
cmd.Parameters.AddWithValue("@model", (object?)e.Model ?? DBNull.Value);
cmd.Parameters.AddWithValue("@system_prompt", (object?)e.SystemPrompt ?? DBNull.Value);
cmd.Parameters.AddWithValue("@agent_path", (object?)e.AgentPath ?? DBNull.Value);
}
private static TaskEntity ReadTask(SqliteDataReader r) => new()
{
Id = r.GetString(0),
ListId = r.GetString(1),
Title = r.GetString(2),
Description = r.IsDBNull(3) ? null : r.GetString(3),
Status = FromDb(r.GetString(4)),
ScheduledFor = r.IsDBNull(5) ? null : DateTime.Parse(r.GetString(5)),
Result = r.IsDBNull(6) ? null : r.GetString(6),
LogPath = r.IsDBNull(7) ? null : r.GetString(7),
CreatedAt = DateTime.Parse(r.GetString(8)),
StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)),
FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)),
CommitType = r.GetString(11),
Model = r.IsDBNull(12) ? null : r.GetString(12),
SystemPrompt = r.IsDBNull(13) ? null : r.GetString(13),
AgentPath = r.IsDBNull(14) ? null : r.GetString(14),
};
#endregion
}

View File

@@ -1,139 +1,44 @@
using System.Globalization;
using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class TaskRunRepository
{
private readonly SqliteConnectionFactory _factory;
private readonly ClaudeDoDbContext _context;
public TaskRunRepository(SqliteConnectionFactory factory) => _factory = factory;
public TaskRunRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO task_runs (id, task_id, run_number, session_id, is_retry, prompt,
result_markdown, structured_output, error_markdown, exit_code,
turn_count, tokens_in, tokens_out, log_path, started_at, finished_at)
VALUES (@id, @task_id, @run_number, @session_id, @is_retry, @prompt,
@result_markdown, @structured_output, @error_markdown, @exit_code,
@turn_count, @tokens_in, @tokens_out, @log_path, @started_at, @finished_at)
""";
BindRun(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
_context.TaskRuns.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE task_runs SET session_id = @session_id,
result_markdown = @result_markdown,
structured_output = @structured_output,
error_markdown = @error_markdown,
exit_code = @exit_code,
turn_count = @turn_count,
tokens_in = @tokens_in,
tokens_out = @tokens_out,
finished_at = @finished_at
WHERE id = @id
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@session_id", (object?)entity.SessionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@result_markdown", (object?)entity.ResultMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@structured_output", (object?)entity.StructuredOutputJson ?? DBNull.Value);
cmd.Parameters.AddWithValue("@error_markdown", (object?)entity.ErrorMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@exit_code", entity.ExitCode.HasValue ? entity.ExitCode.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@turn_count", entity.TurnCount.HasValue ? entity.TurnCount.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_in", entity.TokensIn.HasValue ? entity.TokensIn.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_out", entity.TokensOut.HasValue ? entity.TokensOut.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@finished_at", entity.FinishedAt.HasValue ? entity.FinishedAt.Value.ToString("o") : DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
_context.TaskRuns.Update(entity);
await _context.SaveChangesAsync(ct);
}
public async Task<TaskRunEntity?> GetByIdAsync(string runId, CancellationToken ct = default)
public async Task<TaskRunEntity?> GetByIdAsync(string id, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE id = @id";
cmd.Parameters.AddWithValue("@id", runId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadRun(reader);
return await _context.TaskRuns.FirstOrDefaultAsync(r => r.Id == id, ct);
}
public async Task<List<TaskRunEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TaskRunEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadRun(reader));
return result;
return await _context.TaskRuns
.Where(r => r.TaskId == taskId)
.OrderBy(r => r.RunNumber)
.ToListAsync(ct);
}
public async Task<TaskRunEntity?> GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number DESC LIMIT 1";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadRun(reader);
return await _context.TaskRuns
.Where(r => r.TaskId == taskId)
.OrderByDescending(r => r.RunNumber)
.FirstOrDefaultAsync(ct);
}
#region Helpers
private static void BindRun(SqliteCommand cmd, TaskRunEntity e)
{
cmd.Parameters.AddWithValue("@id", e.Id);
cmd.Parameters.AddWithValue("@task_id", e.TaskId);
cmd.Parameters.AddWithValue("@run_number", e.RunNumber);
cmd.Parameters.AddWithValue("@session_id", (object?)e.SessionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@is_retry", e.IsRetry ? 1 : 0);
cmd.Parameters.AddWithValue("@prompt", e.Prompt);
cmd.Parameters.AddWithValue("@result_markdown", (object?)e.ResultMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@structured_output", (object?)e.StructuredOutputJson ?? DBNull.Value);
cmd.Parameters.AddWithValue("@error_markdown", (object?)e.ErrorMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@exit_code", e.ExitCode.HasValue ? e.ExitCode.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@turn_count", e.TurnCount.HasValue ? e.TurnCount.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_in", e.TokensIn.HasValue ? e.TokensIn.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_out", e.TokensOut.HasValue ? e.TokensOut.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
}
private static TaskRunEntity ReadRun(SqliteDataReader r) => new()
{
Id = r.GetString(0),
TaskId = r.GetString(1),
RunNumber = r.GetInt32(2),
SessionId = r.IsDBNull(3) ? null : r.GetString(3),
IsRetry = r.GetInt32(4) != 0,
Prompt = r.GetString(5),
ResultMarkdown = r.IsDBNull(6) ? null : r.GetString(6),
StructuredOutputJson = r.IsDBNull(7) ? null : r.GetString(7),
ErrorMarkdown = r.IsDBNull(8) ? null : r.GetString(8),
ExitCode = r.IsDBNull(9) ? null : r.GetInt32(9),
TurnCount = r.IsDBNull(10) ? null : r.GetInt32(10),
TokensIn = r.IsDBNull(11) ? null : r.GetInt32(11),
TokensOut = r.IsDBNull(12) ? null : r.GetInt32(12),
LogPath = r.IsDBNull(13) ? null : r.GetString(13),
StartedAt = r.IsDBNull(14) ? null : DateTime.Parse(r.GetString(14), null, DateTimeStyles.RoundtripKind),
FinishedAt = r.IsDBNull(15) ? null : DateTime.Parse(r.GetString(15), null, DateTimeStyles.RoundtripKind),
};
#endregion
}

View File

@@ -1,102 +1,43 @@
using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class WorktreeRepository
{
private readonly SqliteConnectionFactory _factory;
private readonly ClaudeDoDbContext _context;
public WorktreeRepository(SqliteConnectionFactory factory) => _factory = factory;
private static string ToDb(WorktreeState s) => s switch
{
WorktreeState.Active => "active",
WorktreeState.Merged => "merged",
WorktreeState.Discarded => "discarded",
WorktreeState.Kept => "kept",
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
private static WorktreeState FromDb(string s) => s switch
{
"active" => WorktreeState.Active,
"merged" => WorktreeState.Merged,
"discarded" => WorktreeState.Discarded,
"kept" => WorktreeState.Kept,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
public WorktreeRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO worktrees (task_id, path, branch_name, base_commit, head_commit, diff_stat, state, created_at)
VALUES (@task_id, @path, @branch_name, @base_commit, @head_commit, @diff_stat, @state, @created_at)
""";
cmd.Parameters.AddWithValue("@task_id", entity.TaskId);
cmd.Parameters.AddWithValue("@path", entity.Path);
cmd.Parameters.AddWithValue("@branch_name", entity.BranchName);
cmd.Parameters.AddWithValue("@base_commit", entity.BaseCommit);
cmd.Parameters.AddWithValue("@head_commit", (object?)entity.HeadCommit ?? DBNull.Value);
cmd.Parameters.AddWithValue("@diff_stat", (object?)entity.DiffStat ?? DBNull.Value);
cmd.Parameters.AddWithValue("@state", ToDb(entity.State));
cmd.Parameters.AddWithValue("@created_at", entity.CreatedAt.ToString("o"));
await cmd.ExecuteNonQueryAsync(ct);
_context.Worktrees.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task<WorktreeEntity?> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT task_id, path, branch_name, base_commit, head_commit, diff_stat, state, created_at FROM worktrees WHERE task_id = @task_id";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadWorktree(reader);
return await _context.Worktrees.FirstOrDefaultAsync(w => w.TaskId == taskId, ct);
}
public async Task UpdateHeadAsync(string taskId, string headCommit, string? diffStat, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE worktrees SET head_commit = @head_commit, diff_stat = @diff_stat WHERE task_id = @task_id";
cmd.Parameters.AddWithValue("@task_id", taskId);
cmd.Parameters.AddWithValue("@head_commit", headCommit);
cmd.Parameters.AddWithValue("@diff_stat", (object?)diffStat ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
await _context.Worktrees
.Where(w => w.TaskId == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(w => w.HeadCommit, headCommit)
.SetProperty(w => w.DiffStat, diffStat), ct);
}
public async Task SetStateAsync(string taskId, WorktreeState state, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE worktrees SET state = @state WHERE task_id = @task_id";
cmd.Parameters.AddWithValue("@task_id", taskId);
cmd.Parameters.AddWithValue("@state", ToDb(state));
await cmd.ExecuteNonQueryAsync(ct);
await _context.Worktrees
.Where(w => w.TaskId == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), ct);
}
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM worktrees WHERE task_id = @task_id";
cmd.Parameters.AddWithValue("@task_id", taskId);
await cmd.ExecuteNonQueryAsync(ct);
await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
}
private static WorktreeEntity ReadWorktree(SqliteDataReader r) => new()
{
TaskId = r.GetString(0),
Path = r.GetString(1),
BranchName = r.GetString(2),
BaseCommit = r.GetString(3),
HeadCommit = r.IsDBNull(4) ? null : r.GetString(4),
DiffStat = r.IsDBNull(5) ? null : r.GetString(5),
State = FromDb(r.GetString(6)),
CreatedAt = DateTime.Parse(r.GetString(7)),
};
}

View File

@@ -1,67 +0,0 @@
using System.Reflection;
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data;
/// <summary>
/// Applies the embedded schema.sql script. Safe to call on every start — the script uses
/// IF NOT EXISTS / INSERT OR IGNORE.
/// </summary>
public static class SchemaInitializer
{
private const string ResourceName = "ClaudeDo.Data.schema.sql";
public static void Apply(SqliteConnectionFactory factory)
{
using var conn = factory.Open();
ApplyTo(conn);
}
public static void ApplyTo(SqliteConnection conn)
{
var sql = LoadScript();
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
tx.Commit();
ApplyMigrations(conn);
}
private static void ApplyMigrations(SqliteConnection conn)
{
string[] alterStatements =
[
"ALTER TABLE tasks ADD COLUMN model TEXT NULL",
"ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL",
"ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL",
];
foreach (var sql in alterStatements)
{
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
catch (SqliteException ex) when (ex.SqliteErrorCode == 1)
{
// Column already exists — safe to ignore.
}
}
}
private static string LoadScript()
{
var asm = typeof(SchemaInitializer).Assembly;
using var stream = asm.GetManifestResourceStream(ResourceName)
?? throw new InvalidOperationException(
$"Embedded resource '{ResourceName}' not found in {asm.GetName().Name}. " +
$"Available: {string.Join(", ", asm.GetManifestResourceNames())}");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}

View File

@@ -1,48 +0,0 @@
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data;
/// <summary>
/// Opens <see cref="SqliteConnection"/> instances pointed at <see cref="DbPath"/>.
/// First call ensures the parent directory exists, enables WAL and foreign keys.
/// </summary>
public sealed class SqliteConnectionFactory
{
public string DbPath { get; }
private readonly string _connectionString;
private int _walApplied;
public SqliteConnectionFactory(string dbPath)
{
DbPath = Paths.Expand(dbPath);
Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!);
_connectionString = new SqliteConnectionStringBuilder
{
DataSource = DbPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Shared,
}.ToString();
}
public SqliteConnection Open()
{
var conn = new SqliteConnection(_connectionString);
conn.Open();
// WAL is a persistent DB-level setting; applying it once per process is enough,
// but idempotent so we do it defensively on the first connection we hand out.
if (Interlocked.Exchange(ref _walApplied, 1) == 0)
{
using var pragma = conn.CreateCommand();
pragma.CommandText = "PRAGMA journal_mode=WAL;";
pragma.ExecuteNonQuery();
}
using var fk = conn.CreateCommand();
fk.CommandText = "PRAGMA foreign_keys=ON;";
fk.ExecuteNonQuery();
return conn;
}
}

View File

@@ -110,14 +110,16 @@ public partial class App : Application
sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartServiceStep>());
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
// Stop/Start — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
// Stop — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
// Pulled by Update flow + Repair/Uninstall.
sc.AddSingleton<StopServiceStep>();
// StartServiceStep is also registered as IInstallStep above (fresh-install pipeline).
sc.AddSingleton<StartServiceStep>();
// Runners

View File

@@ -22,7 +22,7 @@ public sealed class InstallContext
public int SignalRPort { get; set; } = 47_821;
public int QueueBackstopIntervalMs { get; set; } = 30_000;
public string ClaudeBin { get; set; } = "claude";
public string ServiceAccount { get; set; } = "LocalSystem";
public string ServiceAccount { get; set; } = "CurrentUser";
public bool AutoStart { get; set; } = true;
public int RestartDelayMs { get; set; } = 5000;

View File

@@ -17,7 +17,7 @@ public sealed class UninstallRunner
_stopService = stopService;
}
public async Task<StepResult> RunAsync(IProgress<string> progress, CancellationToken ct)
public async Task<StepResult> RunAsync(bool removeAppData, IProgress<string> progress, CancellationToken ct)
{
// 1) Validate install dir up front — refuse obviously unsafe paths.
// Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\.
@@ -67,13 +67,16 @@ public sealed class UninstallRunner
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
}
// 6) Delete ~/.todo-app (config + DB + logs) — user opted into full removal.
var appData = Paths.AppDataRoot();
if (Directory.Exists(appData))
// 6) Delete ~/.todo-app (config + DB + logs) — only if user opted in.
if (removeAppData)
{
progress.Report($"Deleting {appData}...");
if (!TryDeleteDir(appData, out var err))
failures.Add($"app data ({appData}): {err}");
var appData = Paths.AppDataRoot();
if (Directory.Exists(appData))
{
progress.Report($"Deleting {appData}...");
if (!TryDeleteDir(appData, out var err))
failures.Add($"app data ({appData}): {err}");
}
}
// 7) If we were launched from inside the install dir (Apps & Features case),

View File

@@ -70,11 +70,16 @@ public sealed class DownloadAndExtractStep : IInstallStep
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
// Only after verification do we touch the install directory.
progress.Report("Clearing previous app/worker binaries...");
progress.Report("Stashing previous app/worker binaries...");
var appDest = Path.Combine(ctx.InstallDirectory, "app");
var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
var appBak = appDest + ".bak";
var workerBak = workerDest + ".bak";
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true);
if (Directory.Exists(appDest)) Directory.Move(appDest, appBak);
if (Directory.Exists(workerDest)) Directory.Move(workerDest, workerBak);
progress.Report("Extracting...");
Directory.CreateDirectory(ctx.InstallDirectory);
@@ -84,11 +89,19 @@ public sealed class DownloadAndExtractStep : IInstallStep
}
catch (Exception ex)
{
// Roll back to previous binaries.
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
if (Directory.Exists(appBak)) Directory.Move(appBak, appDest);
if (Directory.Exists(workerBak)) Directory.Move(workerBak, workerDest);
return StepResult.Fail(
$"Extraction failed after old binaries were removed: {ex.Message}. " +
"Your install directory may be incomplete. Re-run the installer to retry.");
$"Extraction failed; previous binaries have been restored: {ex.Message}.");
}
// Success — drop stash.
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true);
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
return StepResult.Ok();
}

View File

@@ -1,5 +1,6 @@
using ClaudeDo.Data;
using ClaudeDo.Installer.Core;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Installer.Steps;
@@ -14,8 +15,11 @@ public sealed class InitDatabaseStep : IInstallStep
var expandedPath = Paths.Expand(ctx.DbPath);
progress.Report($"Initializing database at {expandedPath}");
var factory = new SqliteConnectionFactory(expandedPath);
SchemaInitializer.Apply(factory);
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={expandedPath}")
.Options;
using var context = new ClaudeDoDbContext(options);
ClaudeDoDbContext.MigrateAndConfigure(context);
progress.Report("Schema applied successfully");
return Task.FromResult(StepResult.Ok());

View File

@@ -46,10 +46,9 @@ public sealed class RegisterServiceStep : IInstallStep
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
if (ctx.ServiceAccount == "CurrentUser")
{
var username = Environment.UserName;
createArgs += $" obj= \".\\{username}\"";
}
return StepResult.Fail(
"Service cannot run as Current User without a password. " +
"Select 'Local System' or extend ServicePage to capture a password.");
progress.Report("Creating service...");
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
@@ -68,15 +67,6 @@ public sealed class RegisterServiceStep : IInstallStep
if (failExit != 0)
progress.Report($"Warning: failed to set restart policy (exit {failExit})");
// Start service if auto-start
if (ctx.AutoStart)
{
progress.Report("Starting service...");
var (startExit, _) = await RunSc($"start {ServiceName}", ctx, progress, ct);
if (startExit != 0)
progress.Report("Warning: service created but failed to start. You may need to start it manually.");
}
return StepResult.Ok();
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
@@ -10,13 +11,15 @@ public sealed class WriteConfigStep : IInstallStep
{
try
{
// Expand ~ to the installing user's absolute path so the worker
// service always finds the correct DB regardless of service account.
var workerCfg = new InstallerWorkerConfig
{
DbPath = ctx.DbPath,
SandboxRoot = ctx.SandboxRoot,
LogRoot = ctx.LogRoot,
DbPath = Paths.Expand(ctx.DbPath),
SandboxRoot = Paths.Expand(ctx.SandboxRoot),
LogRoot = Paths.Expand(ctx.LogRoot),
WorktreeRootStrategy = ctx.WorktreeRootStrategy,
CentralWorktreeRoot = ctx.CentralWorktreeRoot,
CentralWorktreeRoot = Paths.Expand(ctx.CentralWorktreeRoot),
QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs,
SignalRPort = ctx.SignalRPort,
ClaudeBin = ctx.ClaudeBin,
@@ -26,7 +29,7 @@ public sealed class WriteConfigStep : IInstallStep
var uiCfg = new InstallerAppSettings
{
DbPath = ctx.UiDbPath,
DbPath = Paths.Expand(ctx.UiDbPath),
SignalRUrl = ctx.SignalRUrl,
};
uiCfg.Save();

View File

@@ -29,6 +29,9 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty]
private string _versionLabel = "";
[ObservableProperty]
private bool _removeAppData;
public SettingsViewModel(
PageResolver resolver,
InstallContext context,
@@ -133,8 +136,12 @@ public partial class SettingsViewModel : ObservableObject
[RelayCommand]
private async Task Uninstall()
{
var dataNote = RemoveAppData
? "This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?"
: "This will remove ClaudeDo. Your tasks, configuration, and database in ~/.todo-app will be kept.\n\nContinue?";
var confirm = MessageBox.Show(
"This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?",
dataNote,
"Uninstall ClaudeDo",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
@@ -142,7 +149,7 @@ public partial class SettingsViewModel : ObservableObject
if (confirm != MessageBoxResult.Yes) return;
var progress = new Progress<string>(msg => StatusMessage = msg);
var r = await _uninstallRunner.RunAsync(progress, CancellationToken.None);
var r = await _uninstallRunner.RunAsync(RemoveAppData, progress, CancellationToken.None);
if (!r.Success)
{

View File

@@ -69,6 +69,7 @@
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Status message / version label -->
@@ -88,14 +89,17 @@
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Uninstall" Margin="0,0,8,0"
<CheckBox Grid.Column="1" IsChecked="{Binding RemoveAppData}"
Content="Remove user data (tasks, logs, configs in ~/.todo-app)"
Margin="0,0,12,0" VerticalAlignment="Center"/>
<Button Grid.Column="2" Content="Uninstall" Margin="0,0,8,0"
Command="{Binding UninstallCommand}"/>
<Button Grid.Column="2" Content="Repair" Margin="0,0,8,0"
<Button Grid.Column="3" Content="Repair" Margin="0,0,8,0"
Command="{Binding RepairCommand}"/>
<Button Grid.Column="3" Content="Save" Margin="0,0,8,0"
<Button Grid.Column="4" Content="Save" Margin="0,0,8,0"
Command="{Binding SaveCommand}"
Style="{StaticResource AccentButton}"/>
<Button Grid.Column="4" Content="Close"
<Button Grid.Column="5" Content="Close"
Command="{Binding CloseCommand}"/>
</Grid>
</Border>

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client;
namespace ClaudeDo.Ui.Services;
@@ -208,9 +209,13 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
ActiveTasks.Add(new ActiveTask(a.Slot, a.TaskId, a.StartedAt));
});
}
catch
catch (HubException)
{
// Worker might not support GetActive yet
// Expected: worker doesn't support GetActive yet
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"SeedActiveTasksAsync failed: {ex}");
}
}

View File

@@ -54,6 +54,7 @@ public partial class ListEditorViewModel : ViewModelBase
public void InitForCreate()
{
_tcs = new TaskCompletionSource<ListEntity?>();
_editId = null;
_createdAt = DateTime.UtcNow;
WindowTitle = "New List";
@@ -61,6 +62,7 @@ public partial class ListEditorViewModel : ViewModelBase
public void InitForEdit(ListEntity entity, ListConfigEntity? config)
{
_tcs = new TaskCompletionSource<ListEntity?>();
_editId = entity.Id;
_createdAt = entity.CreatedAt;
Name = entity.Name;
@@ -119,9 +121,5 @@ public partial class ListEditorViewModel : ViewModelBase
_tcs.TrySetResult(null);
}
public Task<ListEntity?> ShowAndWaitAsync()
{
_tcs = new TaskCompletionSource<ListEntity?>();
return _tcs.Task;
}
public Task<ListEntity?> ShowAndWaitAsync() => _tcs.Task;
}

View File

@@ -2,18 +2,20 @@ using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Views;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
public partial class MainWindowViewModel : ViewModelBase, IDisposable
{
private readonly ListRepository _listRepo;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker;
private readonly Func<ListEditorViewModel> _listEditorFactory;
@@ -25,30 +27,41 @@ public partial class MainWindowViewModel : ViewModelBase
public TaskDetailViewModel TaskDetail { get; }
public StatusBarViewModel StatusBar { get; }
private readonly Action<string> _onTaskChanged;
public MainWindowViewModel(
ListRepository listRepo,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorkerClient worker,
TaskListViewModel taskList,
TaskDetailViewModel taskDetail,
StatusBarViewModel statusBar,
Func<ListEditorViewModel> listEditorFactory)
{
_listRepo = listRepo;
_dbFactory = dbFactory;
_worker = worker;
_listEditorFactory = listEditorFactory;
TaskList = taskList;
TaskDetail = taskDetail;
StatusBar = statusBar;
_onTaskChanged = taskId => _ = TaskList.RefreshSingleAsync(taskId);
TaskList.SelectedTaskChanged += OnSelectedTaskChanged;
TaskDetail.TaskChanged += taskId => _ = TaskList.RefreshSingleAsync(taskId);
TaskDetail.TaskChanged += _onTaskChanged;
}
public void Dispose()
{
TaskList.SelectedTaskChanged -= OnSelectedTaskChanged;
TaskDetail.TaskChanged -= _onTaskChanged;
}
public async Task InitializeAsync()
{
try
{
var lists = await _listRepo.GetAllAsync();
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
var lists = await listRepo.GetAllAsync();
foreach (var l in lists)
Lists.Add(new ListItemViewModel(l));
}
@@ -57,7 +70,11 @@ public partial class MainWindowViewModel : ViewModelBase
StatusBar.ShowMessage($"Error loading lists: {ex.Message}");
}
_ = _worker.StartAsync();
_ = _worker.StartAsync().ContinueWith(t =>
{
if (t.IsFaulted)
System.Diagnostics.Debug.WriteLine($"Worker connection failed: {t.Exception?.Message}");
}, TaskScheduler.Default);
}
partial void OnSelectedListChanged(ListItemViewModel? value)
@@ -91,10 +108,12 @@ public partial class MainWindowViewModel : ViewModelBase
try
{
await _listRepo.AddAsync(entity);
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
await listRepo.AddAsync(entity);
var configEntity = editor.BuildConfig(entity.Id);
if (configEntity is not null)
await _listRepo.SetConfigAsync(configEntity);
await listRepo.SetConfigAsync(configEntity);
Lists.Add(new ListItemViewModel(entity));
}
catch (Exception ex)
@@ -107,10 +126,17 @@ public partial class MainWindowViewModel : ViewModelBase
private async Task EditList()
{
if (SelectedList is null) return;
var existing = await _listRepo.GetByIdAsync(SelectedList.Id);
if (existing is null) return;
var config = await _listRepo.GetConfigAsync(existing.Id);
ListEntity? existing;
ListConfigEntity? config;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
existing = await listRepo.GetByIdAsync(SelectedList.Id);
if (existing is null) return;
config = await listRepo.GetConfigAsync(existing.Id);
}
var editor = _listEditorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForEdit(existing, config);
@@ -125,10 +151,12 @@ public partial class MainWindowViewModel : ViewModelBase
try
{
await _listRepo.UpdateAsync(entity);
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
await listRepo.UpdateAsync(entity);
var configEntity = editor.BuildConfig(entity.Id);
if (configEntity is not null)
await _listRepo.SetConfigAsync(configEntity);
await listRepo.SetConfigAsync(configEntity);
SelectedList.Name = entity.Name;
SelectedList.WorkingDir = entity.WorkingDir;
SelectedList.DefaultCommitType = entity.DefaultCommitType;
@@ -139,21 +167,46 @@ public partial class MainWindowViewModel : ViewModelBase
}
}
[ObservableProperty] private bool _isDeleteConfirmVisible;
private ListItemViewModel? _pendingDeleteList;
[RelayCommand]
private async Task DeleteList()
private void DeleteList()
{
if (SelectedList is null) return;
// TODO: confirmation dialog
_pendingDeleteList = SelectedList;
IsDeleteConfirmVisible = true;
}
[RelayCommand]
private async Task ConfirmDeleteList()
{
IsDeleteConfirmVisible = false;
if (_pendingDeleteList is null) return;
try
{
await _listRepo.DeleteAsync(SelectedList.Id);
Lists.Remove(SelectedList);
SelectedList = null;
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
await listRepo.DeleteAsync(_pendingDeleteList.Id);
Lists.Remove(_pendingDeleteList);
if (SelectedList == _pendingDeleteList)
SelectedList = null;
}
catch (Exception ex)
{
StatusBar.ShowMessage($"Error deleting list: {ex.Message}");
}
finally
{
_pendingDeleteList = null;
}
}
[RelayCommand]
private void CancelDeleteList()
{
IsDeleteConfirmVisible = false;
_pendingDeleteList = null;
}
private static async Task ShowDialogAsync(Window dialog)

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
@@ -9,18 +10,15 @@ using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskDetailViewModel : ViewModelBase
{
private readonly TaskRepository _taskRepo;
private readonly WorktreeRepository _worktreeRepo;
private readonly ListRepository _listRepo;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly WorkerClient _worker;
private readonly TagRepository _tagRepo;
private readonly SubtaskRepository _subtaskRepo;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string? _description;
@@ -62,17 +60,11 @@ public partial class TaskDetailViewModel : ViewModelBase
public event Action<string>? TaskChanged;
public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo,
ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo,
SubtaskRepository subtaskRepo)
public TaskDetailViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, GitService git, WorkerClient worker)
{
_taskRepo = taskRepo;
_worktreeRepo = worktreeRepo;
_listRepo = listRepo;
_dbFactory = dbFactory;
_git = git;
_worker = worker;
_tagRepo = tagRepo;
_subtaskRepo = subtaskRepo;
worker.TaskMessageEvent += OnTaskMessage;
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
@@ -93,13 +85,35 @@ public partial class TaskDetailViewModel : ViewModelBase
var ct = cts.Token;
_taskId = taskId;
HasWorktree = false;
WorktreeState = "";
BranchName = null;
DiffStat = null;
WorktreePath = null;
OnPropertyChanged(nameof(CanWorktreeAction));
LiveText = "";
_formatter = new StreamLineFormatter();
try
{
var task = await _taskRepo.GetByIdAsync(taskId, ct);
if (task is null) return;
TaskEntity? task;
List<TagEntity> tags;
List<SubtaskEntity> subtasks;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
task = await taskRepo.GetByIdAsync(taskId, ct);
if (task is null) return;
ct.ThrowIfCancellationRequested();
tags = await taskRepo.GetTagsAsync(taskId, ct);
ct.ThrowIfCancellationRequested();
var subtaskRepo = new SubtaskRepository(context);
subtasks = await subtaskRepo.GetByTaskIdAsync(taskId, ct);
}
ct.ThrowIfCancellationRequested();
if (AvailableAgents.Count == 0)
@@ -149,14 +163,12 @@ public partial class TaskDetailViewModel : ViewModelBase
}
Tags.Clear();
var tags = await _taskRepo.GetTagsAsync(taskId, ct);
foreach (var tag in tags)
Tags.Add(tag);
// Tear down old subtask subscriptions before replacing them.
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Clear();
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId, ct);
foreach (var s in subtasks)
{
var vm = SubtaskItemViewModel.From(s);
@@ -181,7 +193,9 @@ public partial class TaskDetailViewModel : ViewModelBase
{
if (_isLoading || _taskId is null) return;
var entity = await _taskRepo.GetByIdAsync(_taskId);
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var entity = await taskRepo.GetByIdAsync(_taskId);
if (entity is null) return;
entity.Title = Title;
@@ -196,7 +210,7 @@ public partial class TaskDetailViewModel : ViewModelBase
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
entity.Status = status;
await _taskRepo.UpdateAsync(entity);
await taskRepo.UpdateAsync(entity);
StatusText = entity.Status.ToString().ToLowerInvariant();
TaskChanged?.Invoke(_taskId);
}
@@ -207,11 +221,15 @@ public partial class TaskDetailViewModel : ViewModelBase
var name = NewTagInput.Trim();
if (string.IsNullOrEmpty(name) || _taskId is null) return;
var tagId = await _tagRepo.GetOrCreateAsync(name);
await _taskRepo.AddTagAsync(_taskId, tagId);
using var context = _dbFactory.CreateDbContext();
var tagRepo = new TagRepository(context);
var taskRepo = new TaskRepository(context);
var tagId = await tagRepo.GetOrCreateAsync(name);
await taskRepo.AddTagAsync(_taskId, tagId);
Tags.Clear();
var tags = await _taskRepo.GetTagsAsync(_taskId);
var tags = await taskRepo.GetTagsAsync(_taskId);
foreach (var tag in tags)
Tags.Add(tag);
@@ -223,7 +241,9 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task RemoveTag(TagEntity tag)
{
if (_taskId is null) return;
await _taskRepo.RemoveTagAsync(_taskId, tag.Id);
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
await taskRepo.RemoveTagAsync(_taskId, tag.Id);
Tags.Remove(tag);
TaskChanged?.Invoke(_taskId);
}
@@ -241,7 +261,9 @@ public partial class TaskDetailViewModel : ViewModelBase
OrderNum = Subtasks.Count,
CreatedAt = DateTime.UtcNow,
};
await _subtaskRepo.AddAsync(entity);
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
await subtaskRepo.AddAsync(entity);
var vm = SubtaskItemViewModel.From(entity);
vm.PropertyChanged += OnSubtaskPropertyChanged;
Subtasks.Add(vm);
@@ -251,7 +273,11 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task RemoveSubtask(SubtaskItemViewModel item)
{
if (!string.IsNullOrEmpty(item.Id))
await _subtaskRepo.DeleteAsync(item.Id);
{
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
await subtaskRepo.DeleteAsync(item.Id);
}
item.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Remove(item);
}
@@ -262,14 +288,18 @@ public partial class TaskDetailViewModel : ViewModelBase
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
try
{
await _subtaskRepo.UpdateAsync(new SubtaskEntity
if (_taskId is null) return;
using var context = _dbFactory.CreateDbContext();
var orig = await context.Subtasks.AsNoTracking().FirstOrDefaultAsync(s => s.Id == vm.Id);
var subtaskRepo = new SubtaskRepository(context);
await subtaskRepo.UpdateAsync(new SubtaskEntity
{
Id = vm.Id,
TaskId = _taskId ?? "",
TaskId = _taskId,
Title = vm.Title,
Completed = vm.Completed,
OrderNum = Subtasks.IndexOf(vm),
CreatedAt = DateTime.UtcNow,
CreatedAt = orig?.CreatedAt ?? DateTime.UtcNow,
});
}
catch (Exception ex)
@@ -321,7 +351,9 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task LoadWorktreeAsync(string taskId)
{
var wt = await _worktreeRepo.GetByTaskIdAsync(taskId);
using var context = _dbFactory.CreateDbContext();
var wtRepo = new WorktreeRepository(context);
var wt = await wtRepo.GetByTaskIdAsync(taskId);
HasWorktree = wt is not null;
if (wt is not null)
{
@@ -354,13 +386,15 @@ public partial class TaskDetailViewModel : ViewModelBase
UseShellExecute = true,
});
}
catch { /* best effort */ }
catch (Exception ex)
{
Debug.WriteLine($"Failed to open worktree: {ex.Message}");
}
}
[RelayCommand]
private void ShowDiff()
{
// TODO: open a proper diff viewer; for now open git diff in a console
if (WorktreePath is null) return;
try
{
@@ -371,21 +405,37 @@ public partial class TaskDetailViewModel : ViewModelBase
UseShellExecute = true,
});
}
catch { /* best effort */ }
catch (Exception ex)
{
Debug.WriteLine($"Failed to show diff: {ex.Message}");
}
}
[RelayCommand]
private async Task MergeIntoMainAsync()
{
if (_taskId is null || _listId is null) return;
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
var list = await _listRepo.GetByIdAsync(_listId);
WorktreeEntity? wt;
ListEntity? list;
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
wt = await wtRepo.GetByTaskIdAsync(_taskId);
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(_listId);
}
if (wt is null || list?.WorkingDir is null) return;
await _git.MergeFfOnlyAsync(list.WorkingDir, wt.BranchName);
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Merged);
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Merged);
}
await LoadWorktreeAsync(_taskId);
}
@@ -393,12 +443,25 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task KeepAsBranchAsync()
{
if (_taskId is null || _listId is null) return;
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
var list = await _listRepo.GetByIdAsync(_listId);
WorktreeEntity? wt;
ListEntity? list;
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
wt = await wtRepo.GetByTaskIdAsync(_taskId);
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(_listId);
}
if (wt is null || list?.WorkingDir is null) return;
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Kept);
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Kept);
}
await LoadWorktreeAsync(_taskId);
}
@@ -406,13 +469,26 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task DiscardAsync()
{
if (_taskId is null || _listId is null) return;
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
var list = await _listRepo.GetByIdAsync(_listId);
WorktreeEntity? wt;
ListEntity? list;
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
wt = await wtRepo.GetByTaskIdAsync(_taskId);
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(_listId);
}
if (wt is null || list?.WorkingDir is null) return;
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Discarded);
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Discarded);
}
await LoadWorktreeAsync(_taskId);
}

View File

@@ -1,17 +1,19 @@
using System.Collections.ObjectModel;
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskEditorViewModel : ViewModelBase
{
private readonly SubtaskRepository _subtaskRepo;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string? _description;
@@ -40,9 +42,9 @@ public partial class TaskEditorViewModel : ViewModelBase
public static string[] StatusChoices { get; } =
["manual", "queued"];
public TaskEditorViewModel(SubtaskRepository subtaskRepo)
public TaskEditorViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_subtaskRepo = subtaskRepo;
_dbFactory = dbFactory;
}
public async Task LoadAgentsAsync(WorkerClient worker)
@@ -69,6 +71,7 @@ public partial class TaskEditorViewModel : ViewModelBase
public void InitForCreate(string listId, string defaultCommitType = "chore")
{
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = null;
_listId = listId;
_createdAt = DateTime.UtcNow;
@@ -79,6 +82,7 @@ public partial class TaskEditorViewModel : ViewModelBase
public async Task InitForEditAsync(TaskEntity entity, IReadOnlyList<TagEntity> taskTags, CancellationToken ct = default)
{
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = entity.Id;
_listId = entity.ListId;
_createdAt = entity.CreatedAt;
@@ -116,7 +120,9 @@ public partial class TaskEditorViewModel : ViewModelBase
WindowTitle = $"Edit Task: {entity.Title}";
Subtasks.Clear();
var list = await _subtaskRepo.GetByTaskIdAsync(entity.Id, ct);
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
var list = await subtaskRepo.GetByTaskIdAsync(entity.Id, ct);
foreach (var s in list)
Subtasks.Add(SubtaskItemViewModel.From(s));
}
@@ -124,6 +130,7 @@ public partial class TaskEditorViewModel : ViewModelBase
// Keep old sync overload for callers that haven't loaded agents yet
public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags)
{
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = entity.Id;
_listId = entity.ListId;
_createdAt = entity.CreatedAt;
@@ -196,36 +203,45 @@ public partial class TaskEditorViewModel : ViewModelBase
// Persist subtask changes
if (_editId is not null)
{
var existing = await _subtaskRepo.GetByTaskIdAsync(taskId);
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
var existing = await subtaskRepo.GetByTaskIdAsync(taskId);
var existingIds = existing.Select(s => s.Id).ToHashSet();
var currentIds = Subtasks.Where(s => s.Id != "").Select(s => s.Id).ToHashSet();
// Deleted
foreach (var id in existingIds.Except(currentIds))
await _subtaskRepo.DeleteAsync(id);
await subtaskRepo.DeleteAsync(id);
// Updated
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)))
{
if (vm.Id == "") continue;
if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted)
await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
{
var origSub = existing.FirstOrDefault(e => e.Id == vm.Id);
await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = origSub?.CreatedAt ?? DateTime.UtcNow });
}
else
{
// update order_num if position changed
var orig = existing.FirstOrDefault(e => e.Id == vm.Id);
if (orig is not null && orig.OrderNum != idx)
await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = orig.CreatedAt });
await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = orig.CreatedAt });
}
}
}
// Added (id == "" means new)
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == ""))
{
if (string.IsNullOrWhiteSpace(vm.Title)) continue;
var newId = Guid.NewGuid().ToString();
await _subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == ""))
{
if (string.IsNullOrWhiteSpace(vm.Title)) continue;
var newId = Guid.NewGuid().ToString();
await subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
}
}
_tcs.TrySetResult(entity);
@@ -244,9 +260,5 @@ public partial class TaskEditorViewModel : ViewModelBase
_tcs.TrySetResult(null);
}
public Task<TaskEntity?> ShowAndWaitAsync()
{
_tcs = new TaskCompletionSource<TaskEntity?>();
return _tcs.Task;
}
public Task<TaskEntity?> ShowAndWaitAsync() => _tcs.Task;
}

View File

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

View File

@@ -2,21 +2,21 @@ using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Views;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskListViewModel : ViewModelBase
{
private readonly TaskRepository _taskRepo;
private readonly TagRepository _tagRepo;
private readonly ListRepository _listRepo;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker;
private readonly Func<TaskEditorViewModel> _editorFactory;
private readonly Action<string> _showMessage;
@@ -33,13 +33,10 @@ public partial class TaskListViewModel : ViewModelBase
partial void OnSelectedTaskChanged(TaskItemViewModel? value) =>
SelectedTaskChanged?.Invoke(value);
public TaskListViewModel(TaskRepository taskRepo, TagRepository tagRepo,
ListRepository listRepo, WorkerClient worker,
public TaskListViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker,
Func<TaskEditorViewModel> editorFactory, Action<string> showMessage)
{
_taskRepo = taskRepo;
_tagRepo = tagRepo;
_listRepo = listRepo;
_dbFactory = dbFactory;
_worker = worker;
_editorFactory = editorFactory;
_showMessage = showMessage;
@@ -77,7 +74,9 @@ public partial class TaskListViewModel : ViewModelBase
if (listId is not null)
{
var list = await _listRepo.GetByIdAsync(listId);
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
var list = await listRepo.GetByIdAsync(listId);
ListName = list?.Name ?? "Tasks";
}
else
@@ -89,11 +88,20 @@ public partial class TaskListViewModel : ViewModelBase
try
{
var entities = await _taskRepo.GetByListAsync(listId);
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var entities = await taskRepo.GetByListIdAsync(listId);
var taskIds = entities.Select(e => e.Id).ToList();
var subtaskCounts = await context.Subtasks
.Where(s => taskIds.Contains(s.TaskId))
.GroupBy(s => s.TaskId)
.ToDictionaryAsync(g => g.Key, g => g.Count());
foreach (var e in entities)
{
var tags = await _taskRepo.GetEffectiveTagsAsync(e.Id);
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync));
var tags = await taskRepo.GetEffectiveTagsAsync(e.Id);
subtaskCounts.TryGetValue(e.Id, out var count);
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, count, ToggleDoneAsync));
}
}
catch (Exception ex)
@@ -110,8 +118,13 @@ public partial class TaskListViewModel : ViewModelBase
var title = InlineAddTitle.Trim();
if (string.IsNullOrEmpty(title) || CurrentListId is null) return;
var list = await _listRepo.GetByIdAsync(CurrentListId);
var defaultCommitType = list?.DefaultCommitType ?? "chore";
string defaultCommitType;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
var list = await listRepo.GetByIdAsync(CurrentListId);
defaultCommitType = list?.DefaultCommitType ?? "chore";
}
var entity = new TaskEntity
{
@@ -125,9 +138,12 @@ public partial class TaskListViewModel : ViewModelBase
try
{
await _taskRepo.AddAsync(entity);
var tags = await _taskRepo.GetEffectiveTagsAsync(entity.Id);
var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync);
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
await taskRepo.AddAsync(entity);
var tags = await taskRepo.GetEffectiveTagsAsync(entity.Id);
var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, 0, ToggleDoneAsync);
Tasks.Add(vm);
SelectedTask = vm;
InlineAddTitle = "";
@@ -141,13 +157,20 @@ public partial class TaskListViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanAddTask))]
private async Task AddTask()
{
// Get list default commit type
var list = await _listRepo.GetByIdAsync(CurrentListId);
var defaultCommitType = list?.DefaultCommitType ?? "chore";
var listId = CurrentListId;
if (listId is null) return;
string defaultCommitType;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
var list = await listRepo.GetByIdAsync(listId);
defaultCommitType = list?.DefaultCommitType ?? "chore";
}
var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForCreate(CurrentListId, defaultCommitType);
editor.InitForCreate(listId, defaultCommitType);
var window = new TaskEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
@@ -159,16 +182,20 @@ public partial class TaskListViewModel : ViewModelBase
try
{
await _taskRepo.AddAsync(saved);
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var tagRepo = new TagRepository(context);
await taskRepo.AddAsync(saved);
foreach (var tagName in editor.SelectedTagNames)
{
var tagId = await _tagRepo.GetOrCreateAsync(tagName);
await _taskRepo.AddTagAsync(saved.Id, tagId);
var tagId = await tagRepo.GetOrCreateAsync(tagName);
await taskRepo.AddTagAsync(saved.Id, tagId);
}
var tags = await _taskRepo.GetEffectiveTagsAsync(saved.Id);
Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected, ToggleDoneAsync));
var tags = await taskRepo.GetEffectiveTagsAsync(saved.Id);
Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, 0, ToggleDoneAsync));
// Auto wake-queue if agent+queued
if (saved.Status == TaskStatus.Queued &&
@@ -188,10 +215,17 @@ public partial class TaskListViewModel : ViewModelBase
private async Task EditTask()
{
if (SelectedTask is null || CurrentListId is null) return;
var entity = await _taskRepo.GetByIdAsync(SelectedTask.Id);
if (entity is null) return;
var taskTags = await _taskRepo.GetTagsAsync(entity.Id);
TaskEntity? entity;
List<TagEntity> taskTags;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
entity = await taskRepo.GetByIdAsync(SelectedTask.Id);
if (entity is null) return;
taskTags = await taskRepo.GetTagsAsync(entity.Id);
}
var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
await editor.InitForEditAsync(entity, taskTags);
@@ -206,18 +240,21 @@ public partial class TaskListViewModel : ViewModelBase
try
{
await _taskRepo.UpdateAsync(saved);
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var tagRepo = new TagRepository(context);
await taskRepo.UpdateAsync(saved);
var existingTags = await _taskRepo.GetTagsAsync(saved.Id);
var existingTags = await taskRepo.GetTagsAsync(saved.Id);
foreach (var old in existingTags)
await _taskRepo.RemoveTagAsync(saved.Id, old.Id);
await taskRepo.RemoveTagAsync(saved.Id, old.Id);
foreach (var tagName in editor.SelectedTagNames)
{
var tagId = await _tagRepo.GetOrCreateAsync(tagName);
await _taskRepo.AddTagAsync(saved.Id, tagId);
var tagId = await tagRepo.GetOrCreateAsync(tagName);
await taskRepo.AddTagAsync(saved.Id, tagId);
}
var newTags = await _taskRepo.GetEffectiveTagsAsync(saved.Id);
var newTags = await taskRepo.GetEffectiveTagsAsync(saved.Id);
SelectedTask.Refresh(saved, newTags);
}
catch (Exception ex)
@@ -232,7 +269,9 @@ public partial class TaskListViewModel : ViewModelBase
if (SelectedTask is null) return;
try
{
await _taskRepo.DeleteAsync(SelectedTask.Id);
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
await taskRepo.DeleteAsync(SelectedTask.Id);
Tasks.Remove(SelectedTask);
SelectedTask = null;
}
@@ -244,16 +283,22 @@ public partial class TaskListViewModel : ViewModelBase
public async Task RefreshSingleAsync(string taskId)
{
var entity = await _taskRepo.GetByIdAsync(taskId);
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var entity = await taskRepo.GetByIdAsync(taskId);
var existing = Tasks.FirstOrDefault(t => t.Id == taskId);
if (entity is null)
{
if (existing is not null) Tasks.Remove(existing);
return;
}
var tags = await _taskRepo.GetEffectiveTagsAsync(taskId);
var tags = await taskRepo.GetEffectiveTagsAsync(taskId);
if (existing is not null)
{
existing.Refresh(entity, tags);
var subtaskCount = await context.Subtasks.CountAsync(s => s.TaskId == taskId);
await existing.RefreshSubtasksAsync(subtaskCount);
}
}
private async Task RunNowAsync(string taskId)
@@ -270,21 +315,30 @@ public partial class TaskListViewModel : ViewModelBase
private async Task ToggleDoneAsync(string taskId)
{
var entity = await _taskRepo.GetByIdAsync(taskId);
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var entity = await taskRepo.GetByIdAsync(taskId);
if (entity is null) return;
entity.Status = entity.Status == TaskStatus.Done ? TaskStatus.Manual : TaskStatus.Done;
if (entity.Status == TaskStatus.Done)
entity.FinishedAt = DateTime.UtcNow;
await _taskRepo.UpdateAsync(entity);
await taskRepo.UpdateAsync(entity);
await RefreshSingleAsync(taskId);
}
private async void OnTaskUpdated(string taskId)
{
if (CurrentListId is null) return;
await RefreshSingleAsync(taskId);
try
{
await RefreshSingleAsync(taskId);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[TaskListViewModel] OnTaskUpdated failed for {taskId}: {ex}");
}
}
private static async Task ShowDialogAsync(Window dialog)

View File

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

View File

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

View File

@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.SignalR;
namespace ClaudeDo.Worker.Hub;
public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt);
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{
private static readonly string Version =
@@ -12,19 +14,21 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly QueueService _queue;
private readonly AgentFileService _agentService;
private readonly HubBroadcaster _broadcaster;
public WorkerHub(QueueService queue, AgentFileService agentService)
public WorkerHub(QueueService queue, AgentFileService agentService, HubBroadcaster broadcaster)
{
_queue = queue;
_agentService = agentService;
_broadcaster = broadcaster;
}
public string Ping() => $"pong v{Version}";
public IReadOnlyList<object> GetActive()
public IReadOnlyList<ActiveTaskDto> GetActive()
{
return _queue.GetActive()
.Select(a => (object)new { slot = a.slot, taskId = a.taskId, startedAt = a.startedAt })
.Select(a => new ActiveTaskDto(a.slot, a.taskId, a.startedAt))
.ToList();
}

View File

@@ -5,6 +5,7 @@ using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Services;
using Microsoft.EntityFrameworkCore;
var cfg = WorkerConfig.Load();
@@ -14,18 +15,10 @@ var builder = WebApplication.CreateBuilder(args);
// doesn't think we crashed (~30s timeout). No-op when running interactively.
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
// Initialize DB schema before the host starts accepting connections.
var dbFactory = new SqliteConnectionFactory(cfg.DbPath);
SchemaInitializer.Apply(dbFactory);
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={cfg.DbPath}"));
builder.Services.AddSingleton(cfg);
builder.Services.AddSingleton(dbFactory);
builder.Services.AddSingleton<TagRepository>();
builder.Services.AddSingleton<ListRepository>();
builder.Services.AddSingleton<TaskRepository>();
builder.Services.AddSingleton<SubtaskRepository>();
builder.Services.AddSingleton<WorktreeRepository>();
builder.Services.AddSingleton<TaskRunRepository>();
builder.Services.AddHostedService<StaleTaskRecovery>();
builder.Services.AddSignalR();
@@ -51,6 +44,12 @@ builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
ClaudeDoDbContext.MigrateAndConfigure(
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
}
app.MapHub<WorkerHub>("/hub");
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",

View File

@@ -55,9 +55,15 @@ public sealed class ClaudeArgsBuilder
private static string Escape(string value)
{
if (value.Contains(' ') || value.Contains('"') || value.Contains('\''))
if (value.Contains(' ') || value.Contains('"') || value.Contains('\'')
|| value.Contains('\t') || value.Contains('\n') || value.Contains('\r'))
{
var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\"");
var escaped = value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\n", "\\n")
.Replace("\r", "\\r")
.Replace("\t", "\\t");
return $"\"{escaped}\"";
}
return value;

View File

@@ -1,18 +1,16 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Runner;
public sealed class TaskRunner
{
private readonly IClaudeProcess _claude;
private readonly TaskRepository _taskRepo;
private readonly TaskRunRepository _runRepo;
private readonly ListRepository _listRepo;
private readonly WorktreeRepository _wtRepo;
private readonly SubtaskRepository _subtaskRepo;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly HubBroadcaster _broadcaster;
private readonly WorktreeManager _wtManager;
private readonly ClaudeArgsBuilder _argsBuilder;
@@ -21,11 +19,7 @@ public sealed class TaskRunner
public TaskRunner(
IClaudeProcess claude,
TaskRepository taskRepo,
TaskRunRepository runRepo,
ListRepository listRepo,
WorktreeRepository wtRepo,
SubtaskRepository subtaskRepo,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
HubBroadcaster broadcaster,
WorktreeManager wtManager,
ClaudeArgsBuilder argsBuilder,
@@ -33,11 +27,7 @@ public sealed class TaskRunner
ILogger<TaskRunner> logger)
{
_claude = claude;
_taskRepo = taskRepo;
_runRepo = runRepo;
_listRepo = listRepo;
_wtRepo = wtRepo;
_subtaskRepo = subtaskRepo;
_dbFactory = dbFactory;
_broadcaster = broadcaster;
_wtManager = wtManager;
_argsBuilder = argsBuilder;
@@ -49,11 +39,23 @@ public sealed class TaskRunner
{
try
{
var list = await _listRepo.GetByIdAsync(task.ListId, ct);
if (list is null)
ListEntity? list;
ListConfigEntity? listConfig;
List<SubtaskEntity> subtasks;
using (var context = _dbFactory.CreateDbContext())
{
await MarkFailed(task.Id, slot, "List not found.");
return;
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(task.ListId, ct);
if (list is null)
{
await MarkFailed(task.Id, slot, "List not found.");
return;
}
listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
var subtaskRepo = new SubtaskRepository(context);
subtasks = await subtaskRepo.GetByTaskIdAsync(task.Id, ct);
}
// Determine working directory: worktree or sandbox.
@@ -81,7 +83,6 @@ public sealed class TaskRunner
}
// Resolve config: task overrides > list config > null.
var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
var resolvedConfig = new ClaudeRunConfig(
Model: task.Model ?? listConfig?.Model ?? "claude-sonnet-4-6",
SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
@@ -90,11 +91,14 @@ public sealed class TaskRunner
);
var now = DateTime.UtcNow;
await _taskRepo.MarkRunningAsync(task.Id, now, ct);
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
await taskRepo.MarkRunningAsync(task.Id, now, ct);
}
await _broadcaster.TaskStarted(slot, task.Id, now);
// Build prompt.
var subtasks = await _subtaskRepo.GetByTaskIdAsync(task.Id, ct);
var sb = new System.Text.StringBuilder(task.Title);
if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim());
if (subtasks.Count > 0)
@@ -121,7 +125,6 @@ public sealed class TaskRunner
var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
var retryPrompt = $"The previous attempt failed with:\n\n{result.ErrorMarkdown}\n\nTry again and fix the issues.";
await _broadcaster.RunCreated(task.Id, 2, true);
var retryResult = await RunOnceAsync(task.Id, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
if (retryResult.IsSuccess)
@@ -155,19 +158,34 @@ public sealed class TaskRunner
public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
{
var task = await _taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
TaskEntity task;
TaskRunEntity lastRun;
ListEntity list;
ListConfigEntity? listConfig;
WorktreeEntity? worktree;
var lastRun = await _runRepo.GetLatestByTaskIdAsync(taskId, ct)
?? throw new InvalidOperationException("No previous run to continue.");
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
task = await taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
if (lastRun.SessionId is null)
throw new InvalidOperationException("Previous run has no session ID — cannot resume.");
var runRepo = new TaskRunRepository(context);
lastRun = await runRepo.GetLatestByTaskIdAsync(taskId, ct)
?? throw new InvalidOperationException("No previous run to continue.");
var list = await _listRepo.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
if (lastRun.SessionId is null)
throw new InvalidOperationException("Previous run has no session ID — cannot resume.");
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
var wtRepo = new WorktreeRepository(context);
worktree = await wtRepo.GetByTaskIdAsync(taskId, ct);
}
var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
var resolvedConfig = new ClaudeRunConfig(
Model: task.Model ?? listConfig?.Model,
SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
@@ -178,7 +196,6 @@ public sealed class TaskRunner
// Determine run directory from existing worktree or sandbox.
string runDir;
WorktreeContext? wtCtx = null;
var worktree = await _wtRepo.GetByTaskIdAsync(taskId, ct);
if (worktree is not null)
{
runDir = worktree.Path;
@@ -190,11 +207,14 @@ public sealed class TaskRunner
}
var now = DateTime.UtcNow;
await _taskRepo.MarkRunningAsync(taskId, now, ct);
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
await taskRepo.MarkRunningAsync(taskId, now, ct);
}
await _broadcaster.TaskStarted(slot, taskId, now);
var nextRunNumber = lastRun.RunNumber + 1;
await _broadcaster.RunCreated(taskId, nextRunNumber, false);
var result = await RunOnceAsync(taskId, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
if (result.IsSuccess)
@@ -226,7 +246,14 @@ public sealed class TaskRunner
LogPath = logPath,
StartedAt = DateTime.UtcNow,
};
await _runRepo.AddAsync(run, ct);
using (var context = _dbFactory.CreateDbContext())
{
var runRepo = new TaskRunRepository(context);
await runRepo.AddAsync(run, ct);
}
await _broadcaster.RunCreated(taskId, runNumber, isRetry);
var arguments = _argsBuilder.Build(config);
@@ -257,10 +284,15 @@ public sealed class TaskRunner
run.TokensIn = result.TokensIn;
run.TokensOut = result.TokensOut;
run.FinishedAt = DateTime.UtcNow;
await _runRepo.UpdateAsync(run, CancellationToken.None);
// Update denormalized fields on the task.
await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
using (var context = _dbFactory.CreateDbContext())
{
var runRepo = new TaskRunRepository(context);
await runRepo.UpdateAsync(run, CancellationToken.None);
var taskRepo = new TaskRepository(context);
await taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
}
return result;
}
@@ -273,8 +305,12 @@ public sealed class TaskRunner
run.FinishedAt = DateTime.UtcNow;
try
{
await _runRepo.UpdateAsync(run, CancellationToken.None);
await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
using var context = _dbFactory.CreateDbContext();
var runRepo = new TaskRunRepository(context);
await runRepo.UpdateAsync(run, CancellationToken.None);
var taskRepo = new TaskRepository(context);
await taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
}
catch (Exception updateEx)
{
@@ -297,7 +333,11 @@ public sealed class TaskRunner
// is never left as 'running' because of a cancel that arrived
// after the Claude run already succeeded.
var finishedAt = DateTime.UtcNow;
await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
}
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
@@ -308,7 +348,9 @@ public sealed class TaskRunner
// Intentionally does not accept a CancellationToken: this is the
// terminal write for a failed task and must always be persisted.
var finishedAt = DateTime.UtcNow;
await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
await taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
}
@@ -319,7 +361,9 @@ public sealed class TaskRunner
{
var now = DateTime.UtcNow;
// Terminal write — never cancel.
await _taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
await taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
await _broadcaster.TaskFinished(slot, taskId, "failed", now);
await _broadcaster.TaskUpdated(taskId);
}

View File

@@ -1,7 +1,9 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Runner;
@@ -10,14 +12,14 @@ public sealed record WorktreeContext(string WorktreePath, string BranchName, str
public sealed class WorktreeManager
{
private readonly GitService _git;
private readonly WorktreeRepository _wtRepo;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerConfig _cfg;
private readonly ILogger<WorktreeManager> _logger;
public WorktreeManager(GitService git, WorktreeRepository wtRepo, WorkerConfig cfg, ILogger<WorktreeManager> logger)
public WorktreeManager(GitService git, IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerConfig cfg, ILogger<WorktreeManager> logger)
{
_git = git;
_wtRepo = wtRepo;
_dbFactory = dbFactory;
_cfg = cfg;
_logger = logger;
}
@@ -50,7 +52,9 @@ public sealed class WorktreeManager
await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct);
// Insert worktrees row AFTER git succeeds — if git throws, no row is created.
await _wtRepo.AddAsync(new WorktreeEntity
using var context = _dbFactory.CreateDbContext();
var wtRepo = new WorktreeRepository(context);
await wtRepo.AddAsync(new WorktreeEntity
{
TaskId = task.Id,
Path = worktreePath,
@@ -87,7 +91,9 @@ public sealed class WorktreeManager
var head = await _git.RevParseHeadAsync(ctx.WorktreePath, ct);
var diffStat = await _git.DiffStatAsync(ctx.WorktreePath, ctx.BaseCommit, head, ct);
await _wtRepo.UpdateHeadAsync(task.Id, head, diffStat, ct);
using var context = _dbFactory.CreateDbContext();
var wtRepo = new WorktreeRepository(context);
await wtRepo.UpdateHeadAsync(task.Id, head, diffStat, ct);
_logger.LogInformation("Committed changes for task {TaskId}: {Head}", task.Id, head);
return true;

View File

@@ -1,7 +1,9 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Services;
@@ -14,7 +16,7 @@ public sealed class QueueSlotState
public sealed class QueueService : BackgroundService
{
private readonly TaskRepository _taskRepo;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly TaskRunner _runner;
private readonly WorkerConfig _cfg;
private readonly ILogger<QueueService> _logger;
@@ -26,12 +28,12 @@ public sealed class QueueService : BackgroundService
private readonly SemaphoreSlim _wakeSignal = new(0, 1);
public QueueService(
TaskRepository taskRepo,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
TaskRunner runner,
WorkerConfig cfg,
ILogger<QueueService> logger)
{
_taskRepo = taskRepo;
_dbFactory = dbFactory;
_runner = runner;
_cfg = cfg;
_logger = logger;
@@ -56,20 +58,28 @@ public sealed class QueueService : BackgroundService
public async Task RunNow(string taskId)
{
var task = await _taskRepo.GetByIdAsync(taskId);
if (task is null)
throw new KeyNotFoundException($"Task '{taskId}' not found.");
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
var exists = await taskRepo.GetByIdAsync(taskId);
if (exists is null)
throw new KeyNotFoundException($"Task '{taskId}' not found.");
}
lock (_lock)
{
if (_queueSlot?.TaskId == taskId)
throw new InvalidOperationException("task is already running in queue slot");
if (_overrideSlot is not null)
throw new InvalidOperationException("override slot busy");
var cts = new CancellationTokenSource();
_overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunInSlotAsync(task, "override", cts.Token).ContinueWith(_ =>
_ = RunInSlotAsync(taskId, "override", cts.Token).ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId}", taskId);
lock (_lock) { _overrideSlot = null; }
cts.Dispose();
}, TaskScheduler.Default);
@@ -78,22 +88,28 @@ public sealed class QueueService : BackgroundService
public async Task<string> ContinueTask(string taskId, string followUpPrompt)
{
var task = await _taskRepo.GetByIdAsync(taskId)
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var task = await taskRepo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
if (task.Status == Data.Models.TaskStatus.Running)
throw new InvalidOperationException("Task is currently running.");
throw new InvalidOperationException("task is already running");
lock (_lock)
{
if (_queueSlot?.TaskId == taskId)
throw new InvalidOperationException("task is already running in queue slot");
if (_overrideSlot is not null)
throw new InvalidOperationException("override slot busy");
var cts = new CancellationTokenSource();
_overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(_ =>
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "RunContinueInSlotAsync failed for task {TaskId}", taskId);
lock (_lock) { _overrideSlot = null; }
cts.Dispose();
}, TaskScheduler.Default);
@@ -144,7 +160,12 @@ public sealed class QueueService : BackgroundService
if (_queueSlot is not null) continue;
var task = await _taskRepo.GetNextQueuedAgentTaskAsync(DateTime.UtcNow, stoppingToken);
TaskEntity? task;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
task = await taskRepo.GetNextQueuedAgentTaskAsync(DateTime.UtcNow, stoppingToken);
}
if (task is null) continue;
lock (_lock)
@@ -154,8 +175,10 @@ public sealed class QueueService : BackgroundService
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
_queueSlot = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunInSlotAsync(task, "queue", cts.Token).ContinueWith(_ =>
_ = RunInSlotAsync(task.Id, "queue", cts.Token).ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id);
lock (_lock) { _queueSlot = null; }
cts.Dispose();
WakeQueue(); // Check for next task immediately.
@@ -175,16 +198,25 @@ public sealed class QueueService : BackgroundService
_logger.LogInformation("QueueService stopping");
}
private async Task RunInSlotAsync(TaskEntity task, string slot, CancellationToken ct)
private async Task RunInSlotAsync(string taskId, string slot, CancellationToken ct)
{
try
{
_logger.LogInformation("Starting task {TaskId} in {Slot} slot", task.Id, slot);
_logger.LogInformation("Starting task {TaskId} in {Slot} slot", taskId, slot);
TaskEntity task;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
task = await taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
}
await _runner.RunAsync(task, slot, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Slot runner error for task {TaskId}", task.Id);
_logger.LogError(ex, "Slot runner error for task {TaskId}", taskId);
}
}

View File

@@ -1,21 +1,25 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Services;
public sealed class StaleTaskRecovery : IHostedService
{
private readonly TaskRepository _tasks;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly ILogger<StaleTaskRecovery> _logger;
public StaleTaskRecovery(TaskRepository tasks, ILogger<StaleTaskRecovery> logger)
public StaleTaskRecovery(IDbContextFactory<ClaudeDoDbContext> dbFactory, ILogger<StaleTaskRecovery> logger)
{
_tasks = tasks;
_dbFactory = dbFactory;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var flipped = await _tasks.FlipAllRunningToFailedAsync("worker restart", cancellationToken);
using var context = _dbFactory.CreateDbContext();
var tasks = new TaskRepository(context);
var flipped = await tasks.FlipAllRunningToFailedAsync("worker restart", cancellationToken);
if (flipped > 0)
_logger.LogWarning("Stale task recovery: flipped {Count} running task(s) to failed", flipped);
else

View File

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

View File

@@ -1,19 +1,30 @@
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Tests.Infrastructure;
public sealed class DbFixture : IDisposable
{
public string DbPath { get; }
public SqliteConnectionFactory Factory { get; }
public DbFixture()
{
DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db");
Factory = new SqliteConnectionFactory(DbPath);
SchemaInitializer.Apply(Factory);
// Apply migrations so the schema is created.
using var ctx = CreateContext();
ctx.Database.Migrate();
}
public ClaudeDoDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={DbPath}")
.Options;
return new ClaudeDoDbContext(options);
}
public TestDbContextFactory CreateFactory() => new(this);
public void Dispose()
{
try { File.Delete(DbPath); } catch { /* best effort */ }
@@ -21,3 +32,10 @@ public sealed class DbFixture : IDisposable
try { File.Delete(DbPath + "-shm"); } catch { }
}
}
public sealed class TestDbContextFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly DbFixture _fixture;
public TestDbContextFactory(DbFixture fixture) => _fixture = fixture;
public ClaudeDoDbContext CreateDbContext() => _fixture.CreateContext();
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -7,12 +8,14 @@ namespace ClaudeDo.Worker.Tests.Repositories;
public sealed class ListRepositoryConfigTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly ListRepository _repo;
private readonly string _listId;
public ListRepositoryConfigTests()
{
_repo = new ListRepository(_db.Factory);
_ctx = _db.CreateContext();
_repo = new ListRepository(_ctx);
_listId = Guid.NewGuid().ToString();
_repo.AddAsync(new ListEntity
{
@@ -57,5 +60,9 @@ public sealed class ListRepositoryConfigTests : IDisposable
Assert.Equal("haiku-4-5", fetched.Model);
}
public void Dispose() => _db.Dispose();
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -7,16 +8,22 @@ namespace ClaudeDo.Worker.Tests.Repositories;
public sealed class ListRepositoryTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly ListRepository _lists;
private readonly TagRepository _tags;
public ListRepositoryTests()
{
_lists = new ListRepository(_db.Factory);
_tags = new TagRepository(_db.Factory);
_ctx = _db.CreateContext();
_lists = new ListRepository(_ctx);
_tags = new TagRepository(_ctx);
}
public void Dispose() => _db.Dispose();
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
[Fact]
public async Task AddAsync_And_GetByIdAsync_Roundtrips()

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -8,18 +9,24 @@ namespace ClaudeDo.Worker.Tests.Repositories;
public sealed class TaskRepositoryTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly TagRepository _tags;
public TaskRepositoryTests()
{
_tasks = new TaskRepository(_db.Factory);
_lists = new ListRepository(_db.Factory);
_tags = new TagRepository(_db.Factory);
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
_tags = new TagRepository(_ctx);
}
public void Dispose() => _db.Dispose();
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private async Task<string> CreateListAsync(string? id = null)
{
@@ -197,7 +204,7 @@ public sealed class TaskRepositoryTests : IDisposable
var listId = await CreateListAsync();
var agentTagId = await _tags.GetOrCreateAsync("agent");
var manualTagId = await _tags.GetOrCreateAsync("manual");
var codeTagId = await TagRepository.GetOrCreateAsync(_db.Factory.Open(), "code");
var codeTagId = await _tags.GetOrCreateAsync("code");
await _lists.AddTagAsync(listId, agentTagId);

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
@@ -7,16 +8,18 @@ namespace ClaudeDo.Worker.Tests.Repositories;
public sealed class TaskRunRepositoryTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRunRepository _runs;
private readonly string _taskId;
public TaskRunRepositoryTests()
{
_runs = new TaskRunRepository(_db.Factory);
_ctx = _db.CreateContext();
_runs = new TaskRunRepository(_ctx);
// Seed a list and task for all tests
var lists = new ListRepository(_db.Factory);
var tasks = new TaskRepository(_db.Factory);
var lists = new ListRepository(_ctx);
var tasks = new TaskRepository(_ctx);
var listId = Guid.NewGuid().ToString();
lists.AddAsync(new ListEntity
{
@@ -37,7 +40,11 @@ public sealed class TaskRunRepositoryTests : IDisposable
}).GetAwaiter().GetResult();
}
public void Dispose() => _db.Dispose();
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private TaskRunEntity MakeRun(int runNumber, bool isRetry = false) => new()
{

View File

@@ -68,4 +68,28 @@ public sealed class ClaudeArgsBuilderTests
var args = _builder.Build(new ClaudeRunConfig(null, """Don't say "hello".""", null, null));
Assert.Contains("--append-system-prompt", args);
}
[Fact]
public void Build_quotes_system_prompt_with_newline()
{
var args = _builder.Build(new ClaudeRunConfig(
Model: null,
SystemPrompt: "line1\nline2",
AgentPath: null,
ResumeSessionId: null));
Assert.Contains("--append-system-prompt \"line1\\nline2\"", args);
}
[Fact]
public void Build_quotes_system_prompt_with_tab()
{
var args = _builder.Build(new ClaudeRunConfig(
Model: null,
SystemPrompt: "col1\tcol2",
AgentPath: null,
ResumeSessionId: null));
Assert.Contains("\"col1", args);
}
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
@@ -24,19 +25,19 @@ public class WorktreeManagerTests : IDisposable
return f;
}
private async Task<(WorktreeManager mgr, WorktreeRepository wtRepo)> CreateManagerAsync(
private async Task<(WorktreeManager mgr, DbFixture db)> CreateManagerAsync(
TaskEntity task, ListEntity list, string strategy = "sibling", string? centralRoot = null)
{
var db = new DbFixture();
_dbFixtures.Add(db);
// Seed the DB with list and task so FK constraints pass.
var listRepo = new ListRepository(db.Factory);
var taskRepo = new TaskRepository(db.Factory);
using var seedCtx = db.CreateContext();
var listRepo = new ListRepository(seedCtx);
var taskRepo = new TaskRepository(seedCtx);
await listRepo.AddAsync(list);
await taskRepo.AddAsync(task);
var wtRepo = new WorktreeRepository(db.Factory);
var cfg = new WorkerConfig
{
WorktreeRootStrategy = strategy,
@@ -45,8 +46,8 @@ public class WorktreeManagerTests : IDisposable
cfg.CentralWorktreeRoot = centralRoot;
var mgr = new WorktreeManager(
new GitService(), wtRepo, cfg, NullLogger<WorktreeManager>.Instance);
return (mgr, wtRepo);
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
return (mgr, db);
}
[Fact]
@@ -56,7 +57,7 @@ public class WorktreeManagerTests : IDisposable
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, wtRepo) = await CreateManagerAsync(task, list);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
@@ -66,6 +67,8 @@ public class WorktreeManagerTests : IDisposable
Assert.Equal($"claudedo/{task.Id.Replace("-", "")}", ctx.BranchName);
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
using var readCtx = db.CreateContext();
var wtRepo = new WorktreeRepository(readCtx);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.NotNull(row);
Assert.Equal(WorktreeState.Active, row!.State);
@@ -80,7 +83,7 @@ public class WorktreeManagerTests : IDisposable
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, wtRepo) = await CreateManagerAsync(task, list);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
@@ -88,6 +91,8 @@ public class WorktreeManagerTests : IDisposable
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
Assert.False(committed);
using var readCtx = db.CreateContext();
var wtRepo = new WorktreeRepository(readCtx);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.Null(row!.HeadCommit);
}
@@ -99,7 +104,7 @@ public class WorktreeManagerTests : IDisposable
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, wtRepo) = await CreateManagerAsync(task, list);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
@@ -109,6 +114,8 @@ public class WorktreeManagerTests : IDisposable
var committed = await mgr.CommitIfChangedAsync(ctx, task, list, CancellationToken.None);
Assert.True(committed);
using var readCtx = db.CreateContext();
var wtRepo = new WorktreeRepository(readCtx);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.NotNull(row!.HeadCommit);
Assert.NotEqual(ctx.BaseCommit, row.HeadCommit);
@@ -129,20 +136,24 @@ public class WorktreeManagerTests : IDisposable
var db = new DbFixture();
_dbFixtures.Add(db);
var listRepo = new ListRepository(db.Factory);
var taskRepo = new TaskRepository(db.Factory);
await listRepo.AddAsync(list);
await taskRepo.AddAsync(task);
using (var seedCtx = db.CreateContext())
{
var listRepo = new ListRepository(seedCtx);
var taskRepo = new TaskRepository(seedCtx);
await listRepo.AddAsync(list);
await taskRepo.AddAsync(task);
}
var wtRepo = new WorktreeRepository(db.Factory);
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(
new GitService(), wtRepo, cfg, NullLogger<WorktreeManager>.Instance);
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => mgr.CreateAsync(task, list, CancellationToken.None));
Assert.Contains("not a git repository", ex.Message);
using var readCtx = db.CreateContext();
var wtRepo = new WorktreeRepository(readCtx);
var row = await wtRepo.GetByTaskIdAsync(task.Id);
Assert.Null(row);
}

View File

@@ -0,0 +1,146 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Services;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Services;
public sealed class QueueServiceSlotGuardTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _taskRepo;
private readonly ListRepository _listRepo;
private readonly TagRepository _tagRepo;
private readonly WorkerConfig _cfg;
private readonly string _tempDir;
public QueueServiceSlotGuardTests()
{
_ctx = _db.CreateContext();
_taskRepo = new TaskRepository(_ctx);
_listRepo = new ListRepository(_ctx);
_tagRepo = new TagRepository(_ctx);
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_slotguard_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
_cfg = new WorkerConfig
{
SandboxRoot = Path.Combine(_tempDir, "sandbox"),
LogRoot = Path.Combine(_tempDir, "logs"),
QueueBackstopIntervalMs = 50,
};
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
try { Directory.Delete(_tempDir, true); } catch { }
}
private (QueueService service, FakeClaudeProcess fakeProcess) CreateService(
Func<string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null)
{
var fake = new FakeClaudeProcess(handler);
var broadcaster = new HubBroadcaster(new FakeHubContext());
var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder();
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance);
return (service, fake);
}
private async Task<string> SeedListWithAgentTagAsync()
{
var listId = Guid.NewGuid().ToString();
await _listRepo.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow });
var tags = await _tagRepo.GetAllAsync();
var agentTag = tags.First(t => t.Name == "agent");
await _listRepo.AddTagAsync(listId, agentTag.Id);
return listId;
}
private async Task<TaskEntity> SeedQueuedTaskAsync(string listId)
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "Guard test task",
Description = "Test",
Status = TaskStatus.Queued,
CreatedAt = DateTime.UtcNow,
};
await _taskRepo.AddAsync(task);
return task;
}
[Fact]
public async Task RunNow_Throws_When_Task_Already_Running_In_Queue_Slot()
{
var listId = await SeedListWithAgentTagAsync();
var task = await SeedQueuedTaskAsync(listId);
// Gate keeps the queue slot occupied indefinitely.
var tcs = new TaskCompletionSource<RunResult>();
var queuePickedUp = new TaskCompletionSource();
var (service, _) = CreateService(async (_, _, _, _, ct) =>
{
queuePickedUp.TrySetResult();
return await tcs.Task;
});
using var cts = new CancellationTokenSource();
await service.StartAsync(cts.Token);
service.WakeQueue();
// Wait until the queue slot has actually picked up the task.
await queuePickedUp.Task.WaitAsync(TimeSpan.FromSeconds(5));
// Now the same taskId is in the queue slot — RunNow must reject it.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.RunNow(task.Id));
Assert.Contains("already running", ex.Message);
tcs.SetResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" });
cts.Cancel();
}
[Fact]
public async Task ContinueTask_Throws_When_Task_Already_Running_In_Queue_Slot()
{
var listId = await SeedListWithAgentTagAsync();
var task = await SeedQueuedTaskAsync(listId);
var tcs = new TaskCompletionSource<RunResult>();
var queuePickedUp = new TaskCompletionSource();
var (service, _) = CreateService(async (_, _, _, _, ct) =>
{
queuePickedUp.TrySetResult();
return await tcs.Task;
});
using var cts = new CancellationTokenSource();
await service.StartAsync(cts.Token);
service.WakeQueue();
await queuePickedUp.Task.WaitAsync(TimeSpan.FromSeconds(5));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.ContinueTask(task.Id, "follow-up"));
Assert.Contains("already running", ex.Message);
tcs.SetResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" });
cts.Cancel();
}
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
@@ -15,6 +16,7 @@ namespace ClaudeDo.Worker.Tests.Services;
public sealed class QueueServiceTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _taskRepo;
private readonly ListRepository _listRepo;
private readonly TagRepository _tagRepo;
@@ -23,9 +25,10 @@ public sealed class QueueServiceTests : IDisposable
public QueueServiceTests()
{
_taskRepo = new TaskRepository(_db.Factory);
_listRepo = new ListRepository(_db.Factory);
_tagRepo = new TagRepository(_db.Factory);
_ctx = _db.CreateContext();
_taskRepo = new TaskRepository(_ctx);
_listRepo = new ListRepository(_ctx);
_tagRepo = new TagRepository(_ctx);
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
_cfg = new WorkerConfig
@@ -38,6 +41,7 @@ public sealed class QueueServiceTests : IDisposable
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
try { Directory.Delete(_tempDir, true); } catch { }
}
@@ -47,14 +51,12 @@ public sealed class QueueServiceTests : IDisposable
{
var fake = new FakeClaudeProcess(handler);
var broadcaster = new HubBroadcaster(new FakeHubContext());
var wtRepo = new WorktreeRepository(_db.Factory);
var runRepo = new TaskRunRepository(_db.Factory);
var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger<WorktreeManager>.Instance);
var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder();
var subtaskRepo = new SubtaskRepository(_db.Factory);
var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, subtaskRepo, broadcaster, wtManager, argsBuilder, _cfg,
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance);
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance);
return (service, fake);
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Services;
@@ -10,16 +11,22 @@ namespace ClaudeDo.Worker.Tests.Services;
public sealed class StaleTaskRecoveryTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public StaleTaskRecoveryTests()
{
_tasks = new TaskRepository(_db.Factory);
_lists = new ListRepository(_db.Factory);
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose() => _db.Dispose();
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
[Fact]
public async Task StartAsync_Flips_Running_Tasks_To_Failed()
@@ -47,7 +54,7 @@ public sealed class StaleTaskRecoveryTests : IDisposable
await _tasks.AddAsync(running);
await _tasks.AddAsync(queued);
var recovery = new StaleTaskRecovery(_tasks, NullLogger<StaleTaskRecovery>.Instance);
var recovery = new StaleTaskRecovery(_db.CreateFactory(), NullLogger<StaleTaskRecovery>.Instance);
await recovery.StartAsync(CancellationToken.None);
var r = await _tasks.GetByIdAsync(running.Id);