Compare commits
30 Commits
v1.0.0
...
2a8cd97d02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a8cd97d02 | ||
|
|
09e8b1f10b | ||
|
|
92d8d902df | ||
|
|
aa1008dcff | ||
|
|
5f3d41e1f6 | ||
|
|
7d48f34b15 | ||
|
|
51a1bbe6b8 | ||
|
|
ad7c9facaf | ||
|
|
11a4376da5 | ||
|
|
f10ad69863 | ||
|
|
dc4571a338 | ||
|
|
4fb6ba6be8 | ||
|
|
3423919655 | ||
|
|
fca2bdb596 | ||
|
|
721f0cd903 | ||
|
|
32bb52875f | ||
|
|
4f25c3dd40 | ||
| 33fedc7e26 | |||
|
|
4ca48044db | ||
|
|
611454df1e | ||
|
|
8d61b05179 | ||
|
|
7d0ca45a60 | ||
|
|
36484ed45a | ||
|
|
b7be52a623 | ||
|
|
34ca1b018f | ||
|
|
51a5dcbb73 | ||
|
|
f8f13865d2 | ||
|
|
a064865417 | ||
|
|
9236ca6d45 | ||
|
|
9e1f1370bb |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -61,3 +61,4 @@ Desktop.ini
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
design-time.db
|
||||
|
||||
@@ -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
|
||||
|
||||
1722
docs/superpowers/plans/2026-04-16-efcore-migration.md
Normal file
1722
docs/superpowers/plans/2026-04-16-efcore-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
253
docs/superpowers/specs/2026-04-16-efcore-migration-design.md
Normal file
253
docs/superpowers/specs/2026-04-16-efcore-migration-design.md
Normal 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 |
|
||||
118
docs/superpowers/specs/2026-04-16-subtask-tree-view-design.md
Normal file
118
docs/superpowers/specs/2026-04-16-subtask-tree-view-design.md
Normal 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)
|
||||
@@ -1,100 +0,0 @@
|
||||
-- ClaudeDo SQLite schema (single source of truth, 3NF)
|
||||
-- Applied by Worker on first startup. WAL mode is set via PRAGMA after open.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
working_dir TEXT NULL,
|
||||
default_commit_type TEXT NOT NULL DEFAULT 'chore'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('manual','queued','running','done','failed')),
|
||||
scheduled_for TIMESTAMP NULL,
|
||||
result TEXT NULL,
|
||||
log_path TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
started_at TIMESTAMP NULL,
|
||||
finished_at TIMESTAMP NULL,
|
||||
commit_type TEXT NOT NULL DEFAULT 'chore'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_list_id ON tasks(list_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_tags (
|
||||
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (list_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_tags (
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (task_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_config (
|
||||
list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE,
|
||||
model TEXT NULL,
|
||||
system_prompt TEXT NULL,
|
||||
agent_path TEXT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worktrees (
|
||||
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
base_commit TEXT NOT NULL,
|
||||
head_commit TEXT NULL,
|
||||
diff_stat TEXT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active','merged','discarded','kept')),
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
run_number INTEGER NOT NULL,
|
||||
session_id TEXT NULL,
|
||||
is_retry INTEGER NOT NULL DEFAULT 0,
|
||||
prompt TEXT NOT NULL,
|
||||
result_markdown TEXT NULL,
|
||||
structured_output TEXT NULL,
|
||||
error_markdown TEXT NULL,
|
||||
exit_code INTEGER NULL,
|
||||
turn_count INTEGER NULL,
|
||||
tokens_in INTEGER NULL,
|
||||
tokens_out INTEGER NULL,
|
||||
log_path TEXT NULL,
|
||||
started_at TIMESTAMP NULL,
|
||||
finished_at TIMESTAMP NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subtasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
order_num INTEGER NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_subtasks_task_id ON subtasks(task_id);
|
||||
|
||||
-- Seed: minimal tag set (ignored if already present)
|
||||
INSERT OR IGNORE INTO tags (name) VALUES ('agent');
|
||||
INSERT OR IGNORE INTO tags (name) VALUES ('manual');
|
||||
@@ -5,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>(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
77
src/ClaudeDo.Data/ClaudeDoDbContext.cs
Normal file
77
src/ClaudeDo.Data/ClaudeDoDbContext.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
15
src/ClaudeDo.Data/ClaudeDoDbContextFactory.cs
Normal file
15
src/ClaudeDo.Data/ClaudeDoDbContextFactory.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
public sealed class ClaudeDoDbContextFactory : IDesignTimeDbContextFactory<ClaudeDoDbContext>
|
||||
{
|
||||
public ClaudeDoDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite("Data Source=design-time.db")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfigEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ListConfigEntity> builder)
|
||||
{
|
||||
builder.ToTable("list_config");
|
||||
|
||||
builder.HasKey(c => c.ListId);
|
||||
builder.Property(c => c.ListId).HasColumnName("list_id");
|
||||
builder.Property(c => c.Model).HasColumnName("model");
|
||||
builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt");
|
||||
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
|
||||
}
|
||||
}
|
||||
36
src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs
Normal file
36
src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ListEntity> builder)
|
||||
{
|
||||
builder.ToTable("lists");
|
||||
|
||||
builder.HasKey(l => l.Id);
|
||||
builder.Property(l => l.Id).HasColumnName("id");
|
||||
builder.Property(l => l.Name).HasColumnName("name").IsRequired();
|
||||
builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
|
||||
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
|
||||
|
||||
builder.HasOne(l => l.Config)
|
||||
.WithOne(c => c.List)
|
||||
.HasForeignKey<ListConfigEntity>(c => c.ListId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(l => l.Tags)
|
||||
.WithMany(tag => tag.Lists)
|
||||
.UsingEntity("list_tags",
|
||||
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
|
||||
r => r.HasOne(typeof(ListEntity)).WithMany().HasForeignKey("list_id").OnDelete(DeleteBehavior.Cascade),
|
||||
j =>
|
||||
{
|
||||
j.HasKey("list_id", "tag_id");
|
||||
j.ToTable("list_tags");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class SubtaskEntityConfiguration : IEntityTypeConfiguration<SubtaskEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SubtaskEntity> builder)
|
||||
{
|
||||
builder.ToTable("subtasks");
|
||||
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Property(s => s.Id).HasColumnName("id");
|
||||
builder.Property(s => s.TaskId).HasColumnName("task_id").IsRequired();
|
||||
builder.Property(s => s.Title).HasColumnName("title").IsRequired();
|
||||
builder.Property(s => s.Completed).HasColumnName("completed").IsRequired().HasDefaultValue(false);
|
||||
builder.Property(s => s.OrderNum).HasColumnName("order_num").IsRequired();
|
||||
builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
|
||||
builder.HasOne(s => s.Task)
|
||||
.WithMany(t => t.Subtasks)
|
||||
.HasForeignKey(s => s.TaskId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(s => s.TaskId).HasDatabaseName("idx_subtasks_task_id");
|
||||
}
|
||||
}
|
||||
22
src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs
Normal file
22
src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class TagEntityConfiguration : IEntityTypeConfiguration<TagEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TagEntity> builder)
|
||||
{
|
||||
builder.ToTable("tags");
|
||||
|
||||
builder.HasKey(t => t.Id);
|
||||
builder.Property(t => t.Id).HasColumnName("id").ValueGeneratedOnAdd();
|
||||
builder.Property(t => t.Name).HasColumnName("name").IsRequired();
|
||||
builder.HasIndex(t => t.Name).IsUnique();
|
||||
|
||||
builder.HasData(
|
||||
new TagEntity { Id = 1, Name = "agent" },
|
||||
new TagEntity { Id = 2, Name = "manual" });
|
||||
}
|
||||
}
|
||||
75
src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs
Normal file
75
src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
{
|
||||
private static string StatusToString(TaskStatus v)
|
||||
=> v == TaskStatus.Manual ? "manual"
|
||||
: v == TaskStatus.Queued ? "queued"
|
||||
: v == TaskStatus.Running ? "running"
|
||||
: v == TaskStatus.Done ? "done"
|
||||
: v == TaskStatus.Failed ? "failed"
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
|
||||
private static TaskStatus StatusFromString(string v)
|
||||
=> v == "manual" ? TaskStatus.Manual
|
||||
: v == "queued" ? TaskStatus.Queued
|
||||
: v == "running" ? TaskStatus.Running
|
||||
: v == "done" ? TaskStatus.Done
|
||||
: v == "failed" ? TaskStatus.Failed
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
|
||||
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
|
||||
new(v => StatusToString(v), v => StatusFromString(v));
|
||||
|
||||
public void Configure(EntityTypeBuilder<TaskEntity> builder)
|
||||
{
|
||||
builder.ToTable("tasks");
|
||||
|
||||
builder.HasKey(t => t.Id);
|
||||
builder.Property(t => t.Id).HasColumnName("id");
|
||||
builder.Property(t => t.ListId).HasColumnName("list_id").IsRequired();
|
||||
builder.Property(t => t.Title).HasColumnName("title").IsRequired();
|
||||
builder.Property(t => t.Description).HasColumnName("description");
|
||||
builder.Property(t => t.Status).HasColumnName("status").IsRequired()
|
||||
.HasConversion(StatusConverter);
|
||||
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
|
||||
builder.Property(t => t.Result).HasColumnName("result");
|
||||
builder.Property(t => t.LogPath).HasColumnName("log_path");
|
||||
builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
builder.Property(t => t.StartedAt).HasColumnName("started_at");
|
||||
builder.Property(t => t.FinishedAt).HasColumnName("finished_at");
|
||||
builder.Property(t => t.CommitType).HasColumnName("commit_type").IsRequired().HasDefaultValue("chore");
|
||||
builder.Property(t => t.Model).HasColumnName("model");
|
||||
builder.Property(t => t.SystemPrompt).HasColumnName("system_prompt");
|
||||
builder.Property(t => t.AgentPath).HasColumnName("agent_path");
|
||||
|
||||
builder.HasOne(t => t.List)
|
||||
.WithMany(l => l.Tasks)
|
||||
.HasForeignKey(t => t.ListId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(t => t.Worktree)
|
||||
.WithOne(w => w.Task)
|
||||
.HasForeignKey<WorktreeEntity>(w => w.TaskId);
|
||||
|
||||
builder.HasMany(t => t.Tags)
|
||||
.WithMany(tag => tag.Tasks)
|
||||
.UsingEntity("task_tags",
|
||||
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
|
||||
r => r.HasOne(typeof(TaskEntity)).WithMany().HasForeignKey("task_id").OnDelete(DeleteBehavior.Cascade),
|
||||
j =>
|
||||
{
|
||||
j.HasKey("task_id", "tag_id");
|
||||
j.ToTable("task_tags");
|
||||
});
|
||||
|
||||
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
|
||||
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class TaskRunEntityConfiguration : IEntityTypeConfiguration<TaskRunEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TaskRunEntity> builder)
|
||||
{
|
||||
builder.ToTable("task_runs");
|
||||
|
||||
builder.HasKey(r => r.Id);
|
||||
builder.Property(r => r.Id).HasColumnName("id");
|
||||
builder.Property(r => r.TaskId).HasColumnName("task_id").IsRequired();
|
||||
builder.Property(r => r.RunNumber).HasColumnName("run_number").IsRequired();
|
||||
builder.Property(r => r.SessionId).HasColumnName("session_id");
|
||||
builder.Property(r => r.IsRetry).HasColumnName("is_retry").IsRequired().HasDefaultValue(false);
|
||||
builder.Property(r => r.Prompt).HasColumnName("prompt").IsRequired();
|
||||
builder.Property(r => r.ResultMarkdown).HasColumnName("result_markdown");
|
||||
builder.Property(r => r.StructuredOutputJson).HasColumnName("structured_output");
|
||||
builder.Property(r => r.ErrorMarkdown).HasColumnName("error_markdown");
|
||||
builder.Property(r => r.ExitCode).HasColumnName("exit_code");
|
||||
builder.Property(r => r.TurnCount).HasColumnName("turn_count");
|
||||
builder.Property(r => r.TokensIn).HasColumnName("tokens_in");
|
||||
builder.Property(r => r.TokensOut).HasColumnName("tokens_out");
|
||||
builder.Property(r => r.LogPath).HasColumnName("log_path");
|
||||
builder.Property(r => r.StartedAt).HasColumnName("started_at");
|
||||
builder.Property(r => r.FinishedAt).HasColumnName("finished_at");
|
||||
|
||||
builder.HasOne(r => r.Task)
|
||||
.WithMany(t => t.Runs)
|
||||
.HasForeignKey(r => r.TaskId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(r => r.TaskId).HasDatabaseName("idx_task_runs_task_id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEntity>
|
||||
{
|
||||
private static string StateToString(WorktreeState v)
|
||||
=> v == WorktreeState.Active ? "active"
|
||||
: v == WorktreeState.Merged ? "merged"
|
||||
: v == WorktreeState.Discarded ? "discarded"
|
||||
: v == WorktreeState.Kept ? "kept"
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
|
||||
private static WorktreeState StateFromString(string v)
|
||||
=> v == "active" ? WorktreeState.Active
|
||||
: v == "merged" ? WorktreeState.Merged
|
||||
: v == "discarded" ? WorktreeState.Discarded
|
||||
: v == "kept" ? WorktreeState.Kept
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
|
||||
private static readonly ValueConverter<WorktreeState, string> StateConverter =
|
||||
new(v => StateToString(v), v => StateFromString(v));
|
||||
|
||||
public void Configure(EntityTypeBuilder<WorktreeEntity> builder)
|
||||
{
|
||||
builder.ToTable("worktrees");
|
||||
|
||||
builder.HasKey(w => w.TaskId);
|
||||
builder.Property(w => w.TaskId).HasColumnName("task_id");
|
||||
builder.Property(w => w.Path).HasColumnName("path").IsRequired();
|
||||
builder.Property(w => w.BranchName).HasColumnName("branch_name").IsRequired();
|
||||
builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired();
|
||||
builder.Property(w => w.HeadCommit).HasColumnName("head_commit");
|
||||
builder.Property(w => w.DiffStat).HasColumnName("diff_stat");
|
||||
builder.Property(w => w.State).HasColumnName("state").IsRequired()
|
||||
.HasDefaultValue(WorktreeState.Active)
|
||||
.HasConversion(StateConverter);
|
||||
builder.Property(w => w.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
}
|
||||
}
|
||||
298
src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
Normal file
298
src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
Normal file
@@ -0,0 +1,298 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "lists",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
working_dir = table.Column<string>(type: "TEXT", nullable: true),
|
||||
default_commit_type = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "chore")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_lists", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tags",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
name = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_tags", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "list_config",
|
||||
columns: table => new
|
||||
{
|
||||
list_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
model = table.Column<string>(type: "TEXT", nullable: true),
|
||||
system_prompt = table.Column<string>(type: "TEXT", nullable: true),
|
||||
agent_path = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_list_config", x => x.list_id);
|
||||
table.ForeignKey(
|
||||
name: "FK_list_config_lists_list_id",
|
||||
column: x => x.list_id,
|
||||
principalTable: "lists",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tasks",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
list_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
title = table.Column<string>(type: "TEXT", nullable: false),
|
||||
description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
status = table.Column<string>(type: "TEXT", nullable: false),
|
||||
scheduled_for = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
result = table.Column<string>(type: "TEXT", nullable: true),
|
||||
log_path = table.Column<string>(type: "TEXT", nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
started_at = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
finished_at = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
commit_type = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "chore"),
|
||||
model = table.Column<string>(type: "TEXT", nullable: true),
|
||||
system_prompt = table.Column<string>(type: "TEXT", nullable: true),
|
||||
agent_path = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_tasks", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_tasks_lists_list_id",
|
||||
column: x => x.list_id,
|
||||
principalTable: "lists",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "list_tags",
|
||||
columns: table => new
|
||||
{
|
||||
list_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_list_tags", x => new { x.list_id, x.tag_id });
|
||||
table.ForeignKey(
|
||||
name: "FK_list_tags_lists_list_id",
|
||||
column: x => x.list_id,
|
||||
principalTable: "lists",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_list_tags_tags_tag_id",
|
||||
column: x => x.tag_id,
|
||||
principalTable: "tags",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "subtasks",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
task_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
title = table.Column<string>(type: "TEXT", nullable: false),
|
||||
completed = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
|
||||
order_num = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_subtasks", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_subtasks_tasks_task_id",
|
||||
column: x => x.task_id,
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "task_runs",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
task_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
run_number = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
session_id = table.Column<string>(type: "TEXT", nullable: true),
|
||||
is_retry = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
|
||||
prompt = table.Column<string>(type: "TEXT", nullable: false),
|
||||
result_markdown = table.Column<string>(type: "TEXT", nullable: true),
|
||||
structured_output = table.Column<string>(type: "TEXT", nullable: true),
|
||||
error_markdown = table.Column<string>(type: "TEXT", nullable: true),
|
||||
exit_code = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
turn_count = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
tokens_in = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
tokens_out = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
log_path = table.Column<string>(type: "TEXT", nullable: true),
|
||||
started_at = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
finished_at = table.Column<DateTime>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_task_runs", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_task_runs_tasks_task_id",
|
||||
column: x => x.task_id,
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "task_tags",
|
||||
columns: table => new
|
||||
{
|
||||
task_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_task_tags", x => new { x.task_id, x.tag_id });
|
||||
table.ForeignKey(
|
||||
name: "FK_task_tags_tags_tag_id",
|
||||
column: x => x.tag_id,
|
||||
principalTable: "tags",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_task_tags_tasks_task_id",
|
||||
column: x => x.task_id,
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "worktrees",
|
||||
columns: table => new
|
||||
{
|
||||
task_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
path = table.Column<string>(type: "TEXT", nullable: false),
|
||||
branch_name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
base_commit = table.Column<string>(type: "TEXT", nullable: false),
|
||||
head_commit = table.Column<string>(type: "TEXT", nullable: true),
|
||||
diff_stat = table.Column<string>(type: "TEXT", nullable: true),
|
||||
state = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "active"),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_worktrees", x => x.task_id);
|
||||
table.ForeignKey(
|
||||
name: "FK_worktrees_tasks_task_id",
|
||||
column: x => x.task_id,
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "tags",
|
||||
columns: new[] { "id", "name" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1L, "agent" },
|
||||
{ 2L, "manual" }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_list_tags_tag_id",
|
||||
table: "list_tags",
|
||||
column: "tag_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_subtasks_task_id",
|
||||
table: "subtasks",
|
||||
column: "task_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_tags_name",
|
||||
table: "tags",
|
||||
column: "name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_task_runs_task_id",
|
||||
table: "task_runs",
|
||||
column: "task_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_task_tags_tag_id",
|
||||
table: "task_tags",
|
||||
column: "tag_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_tasks_list_id",
|
||||
table: "tasks",
|
||||
column: "list_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_tasks_status",
|
||||
table: "tasks",
|
||||
column: "status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "list_config");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "list_tags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "subtasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "task_runs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "task_tags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "worktrees");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "lists");
|
||||
}
|
||||
}
|
||||
}
|
||||
479
src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs
Normal file
479
src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs
Normal file
@@ -0,0 +1,479 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
partial class ClaudeDoDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("tags", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1L,
|
||||
Name = "agent"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2L,
|
||||
Name = "manual"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("list_tags", b =>
|
||||
{
|
||||
b.Property<string>("list_id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("tag_id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("list_id", "tag_id");
|
||||
|
||||
b.HasIndex("tag_id");
|
||||
|
||||
b.ToTable("list_tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("task_tags", b =>
|
||||
{
|
||||
b.Property<string>("task_id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("tag_id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("task_id", "tag_id");
|
||||
|
||||
b.HasIndex("tag_id");
|
||||
|
||||
b.ToTable("task_tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("list_tags", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("list_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("tag_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("task_tags", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("tag_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("task_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,7 @@ public sealed class ListConfigEntity
|
||||
public string? Model { get; set; }
|
||||
public string? SystemPrompt { get; set; }
|
||||
public string? AgentPath { get; set; }
|
||||
|
||||
// Navigation property
|
||||
public ListEntity List { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -7,4 +7,9 @@ public sealed class ListEntity
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
public 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>();
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the embedded schema.sql script. Safe to call on every start — the script uses
|
||||
/// IF NOT EXISTS / INSERT OR IGNORE.
|
||||
/// </summary>
|
||||
public static class SchemaInitializer
|
||||
{
|
||||
private const string ResourceName = "ClaudeDo.Data.schema.sql";
|
||||
|
||||
public static void Apply(SqliteConnectionFactory factory)
|
||||
{
|
||||
using var conn = factory.Open();
|
||||
ApplyTo(conn);
|
||||
}
|
||||
|
||||
public static void ApplyTo(SqliteConnection conn)
|
||||
{
|
||||
var sql = LoadScript();
|
||||
using var tx = conn.BeginTransaction();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
tx.Commit();
|
||||
|
||||
ApplyMigrations(conn);
|
||||
}
|
||||
|
||||
private static void ApplyMigrations(SqliteConnection conn)
|
||||
{
|
||||
string[] alterStatements =
|
||||
[
|
||||
"ALTER TABLE tasks ADD COLUMN model TEXT NULL",
|
||||
"ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL",
|
||||
"ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL",
|
||||
];
|
||||
|
||||
foreach (var sql in alterStatements)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
catch (SqliteException ex) when (ex.SqliteErrorCode == 1)
|
||||
{
|
||||
// Column already exists — safe to ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string LoadScript()
|
||||
{
|
||||
var asm = typeof(SchemaInitializer).Assembly;
|
||||
using var stream = asm.GetManifestResourceStream(ResourceName)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Embedded resource '{ResourceName}' not found in {asm.GetName().Name}. " +
|
||||
$"Available: {string.Join(", ", asm.GetManifestResourceNames())}");
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Opens <see cref="SqliteConnection"/> instances pointed at <see cref="DbPath"/>.
|
||||
/// First call ensures the parent directory exists, enables WAL and foreign keys.
|
||||
/// </summary>
|
||||
public sealed class SqliteConnectionFactory
|
||||
{
|
||||
public string DbPath { get; }
|
||||
private readonly string _connectionString;
|
||||
private int _walApplied;
|
||||
|
||||
public SqliteConnectionFactory(string dbPath)
|
||||
{
|
||||
DbPath = Paths.Expand(dbPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!);
|
||||
|
||||
_connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = DbPath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Cache = SqliteCacheMode.Shared,
|
||||
}.ToString();
|
||||
}
|
||||
|
||||
public SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
|
||||
// WAL is a persistent DB-level setting; applying it once per process is enough,
|
||||
// but idempotent so we do it defensively on the first connection we hand out.
|
||||
if (Interlocked.Exchange(ref _walApplied, 1) == 0)
|
||||
{
|
||||
using var pragma = conn.CreateCommand();
|
||||
pragma.CommandText = "PRAGMA journal_mode=WAL;";
|
||||
pragma.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var fk = conn.CreateCommand();
|
||||
fk.CommandText = "PRAGMA foreign_keys=ON;";
|
||||
fk.ExecuteNonQuery();
|
||||
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user