40 Commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:58:57 +02:00
mika kuns
51a5dcbb73 feat(data): add ClaudeDoDbContext with Fluent API configurations 2026-04-16 08:37:37 +02:00
mika kuns
f8f13865d2 feat(data): add navigation properties to all entity models 2026-04-16 08:36:22 +02:00
mika kuns
a064865417 chore(data): swap Microsoft.Data.Sqlite for EF Core packages 2026-04-16 08:35:40 +02:00
mika kuns
9236ca6d45 docs(data): add EF Core migration implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:32:18 +02:00
mika kuns
9e1f1370bb docs(data): add EF Core migration design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:20:33 +02:00
Mika Kuns
3b1f148122 feat(branding): add app and installer icons
All checks were successful
Release / release (push) Successful in 27s
- app: ClaudeTask.ico wired as ApplicationIcon and MainWindow.Icon
  via avares URI
- installer: ClaudeTaskSetup.ico wired as ApplicationIcon and embedded
  as a WPF Resource so WizardWindow and SettingsWindow can reference it
  via pack URI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:28:15 +02:00
Mika Kuns
2b3fe02d8c fix(ui): prevent async void races and leak-on-exit
- task detail vm: LoadAsync now uses a per-call CancellationTokenSource
  so rapid TaskUpdated events can't race on _taskId / Subtasks / Tags;
  old subtask PropertyChanged handlers are torn down before Clear
- task detail vm: async void event handlers (OnTaskUpdated,
  OnWorktreeUpdated, OnSubtaskPropertyChanged) wrap work in try/catch
  so thrown exceptions can't crash the Avalonia sync context
- task detail vm: Clear cancels/disposes the load CTS so a late-arriving
  LoadAsync can't resurrect detail state after deselect
- app: DisposeAsync the ServiceProvider in a finally after the classic
  desktop lifetime ends, so WorkerClient.DisposeAsync runs and the
  SignalR connection closes cleanly instead of being abandoned

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:27:45 +02:00
Mika Kuns
d3b85f2234 fix(worker): address concurrency, cancellation, and resource issues
- claude process: run stdout/stderr reads without ct; rely on
  kill-on-cancel closing the pipes to unblock them — previously
  ReadLineAsync(ct) could hang, stalling task slots and shutdown
- task runner: terminal db writes (task_runs, MarkDone, MarkFailed,
  SetLogPath) now use CancellationToken.None; RunOnceAsync catches
  OCE and finalizes the run row so ContinueAsync can resume
- task repository: GetNextQueuedAgentTaskAsync is now a single
  UPDATE ... RETURNING statement — closes TOCTOU window where two
  loop iterations could dispatch the same queued task
- queue service: dispose CancellationTokenSource in slot-completion
  ContinueWith to stop leaking wait handles
- git service: register ct.Kill(processTree), drain reads without ct,
  always reap via WaitForExitAsync(None) — no more git zombies on
  cancelled worktree ops
- worktree manager: branch name uses full task id (dashes stripped)
  instead of 8-char prefix, eliminating collision risk

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:27:18 +02:00
Mika Kuns
fc9029de97 fix(installer): wait for prior service registration to clear before create
After `sc delete`, the service stays in "marked for deletion" state until
every open handle (services.msc, Task Manager Services tab, Event Viewer,
prior sc query process) is closed. The installer used to immediately call
`sc create` and hit a silent hang / confusing "specified service has been
marked for deletion" error.

Poll `sc query` for up to 30s after delete; if the service is still
registered past that, fail with actionable guidance (close the offending
console or reboot). Also translate exit 1072 from `sc create` into the
same human-readable hint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:54:36 +02:00
Mika Kuns
1c764dae3f fix(installer): publish framework-dependent single-file
All checks were successful
Release / release (push) Successful in 27s
Self-contained single-file bundle was crashing at startup with 0xc0000005
in the apphost bootstrap (COR_E_EXECUTIONENGINE), because the Linux Gitea
runner doesn't carry the Microsoft.WindowsDesktop.App runtime pack — the
resulting bundle was missing WPF runtime bits. Disabling compression alone
didn't resolve it.

Switch to framework-dependent single-file: target machines need the .NET 8
Desktop Runtime (x64) installed but the bundle is ~2 MB instead of ~140 MB
and starts reliably. Keep IncludeAllContentForSelfExtract=true so native
deps (e_sqlite3) extract to a temp dir on first run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:11:27 +02:00
Mika Kuns
cfec3297a4 fix(installer): disable single-file compression to prevent WPF startup AV
All checks were successful
Release / release (push) Successful in 30s
Published installer was crashing at launch with 0xc0000005 (access violation)
and exit code 0x80131506 (COR_E_EXECUTIONENGINE), faulting inside the exe's
own bundle-extractor bootstrap before any managed code ran. This is a known
interaction between WPF, single-file publish, and EnableCompressionInSingleFile.

Disable compression (exe grows ~30% but startup becomes reliable).
IncludeAllContentForSelfExtract stays true — WPF still needs on-disk
extraction for XAML resources.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:48:06 +02:00
Mika Kuns
6e1d64b489 Change sdk Version
All checks were successful
Release / release (push) Successful in 39s
2026-04-15 14:23:26 +02:00
Mika Kuns
f599f8d0af fix(installer,worker): service hosting, dark theme, uninstall polish
Some checks failed
Release / release (push) Failing after 0s
Worker:
- Wire UseWindowsService + Microsoft.Extensions.Hosting.WindowsServices so
  SCM's Service Control Protocol handshake succeeds. Previously the binary
  exited immediately under sc start, leaving the service registered but
  never running.

Installer:
- Pin SDK to .NET 9 (global.json) — SDK 10 dropped win-arm from its RID
  graph, breaking restore of the WPF project; .NET 9 keeps win-arm AND
  understands the .slnx solution format.
- Force SelfContained=true and default RID=win-x64 when PublishSingleFile
  is set, so Rider Publish and CLI produce the same bundle.
- Dark theme: set Background/Foreground explicitly on WizardWindow and
  SettingsWindow roots (WPF implicit styles don't cascade to derived
  Window types). Custom ComboBox template + ComboBoxItem style so
  dropdowns honour the dark palette instead of system defaults.
- Throttle download progress to one report per MB and overwrite the same
  UI line (\r prefix marker) instead of appending per chunk.
- Register ClaudeDo in HKLM\...\Uninstall so it appears in Apps & Features.
  Copy installer into InstallDir\uninstaller\ for the UninstallString, and
  schedule a cmd.exe trampoline to handle the self-delete case when
  Apps & Features launches the copy from inside the install dir.
- Treat sc.exe stop exit 1062 (ERROR_SERVICE_NOT_ACTIVE) as success.
- Delete the uninstall registry key during UninstallRunner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:19:09 +02:00
Mika Kuns
9b928c6217 fix(installer): set EnableWindowsTargeting so Linux Gitea runners can publish
All checks were successful
Release / release (push) Successful in 40s
The release workflow runs on a Linux container; building net8.0-windows +
UseWPF=true requires this opt-in property since .NET 8. No-op on Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:39:23 +02:00
c9e38aef88 Merge pull request 'feat/ui-improvements' (#1) from feat/ui-improvements into main
Some checks failed
Release / release (push) Failing after 19s
Reviewed-on: #1
2026-04-15 09:28:58 +00:00
87 changed files with 5125 additions and 1341 deletions

View File

@@ -67,7 +67,7 @@ jobs:
-c Release -r win-x64 --self-contained true \
/p:Version=$VERSION -o out/worker
- name: Publish ClaudeDo.Installer (win-x64, single-file)
- name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
@@ -75,8 +75,11 @@ jobs:
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd "$WORK/src"
# Framework-dependent — WPF runtime pack isn't distributed on Linux SDK;
# the previous self-contained bundle crashed at startup (apphost AV).
# Target machines need .NET 8 Desktop Runtime (x64).
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
-c Release -r win-x64 --self-contained true \
-c Release -r win-x64 --self-contained false \
/p:Version=$VERSION /p:PublishSingleFile=true \
-o out/installer

1
.gitignore vendored
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

6
global.json Normal file
View File

@@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.418",
"rollForward": "latestFeature"
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 B

View File

@@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\ClaudeTask.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>

View File

@@ -5,26 +5,47 @@ 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
{
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
finally
{
// Dispose the container so WorkerClient.DisposeAsync runs —
// cancels the retry loop and closes the SignalR connection cleanly
// instead of abandoning it.
try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
catch { /* best effort on shutdown */ }
}
}
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
@@ -44,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>();
@@ -61,30 +78,21 @@ sealed class Program
sc.AddTransient<ListEditorViewModel>();
sc.AddTransient<TaskEditorViewModel>();
sc.AddSingleton<StatusBarViewModel>();
sc.AddSingleton<TaskDetailViewModel>(sp => new TaskDetailViewModel(
sp.GetRequiredService<TaskRepository>(),
sp.GetRequiredService<WorktreeRepository>(),
sp.GetRequiredService<ListRepository>(),
sp.GetRequiredService<GitService>(),
sp.GetRequiredService<WorkerClient>(),
sp.GetRequiredService<TagRepository>(),
sp.GetRequiredService<SubtaskRepository>()));
sc.AddSingleton<TaskDetailViewModel>();
sc.AddSingleton<TaskListViewModel>(sp =>
{
var taskRepo = sp.GetRequiredService<TaskRepository>();
var tagRepo = sp.GetRequiredService<TagRepository>();
var listRepo = sp.GetRequiredService<ListRepository>();
var dbFactory = sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>();
var worker = sp.GetRequiredService<WorkerClient>();
var statusBar = sp.GetRequiredService<StatusBarViewModel>();
return new TaskListViewModel(
taskRepo, tagRepo, listRepo, worker,
dbFactory, worker,
() => sp.GetRequiredService<TaskEditorViewModel>(),
msg => statusBar.ShowMessage(msg));
});
sc.AddSingleton<MainWindowViewModel>(sp =>
{
return new MainWindowViewModel(
sp.GetRequiredService<ListRepository>(),
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<WorkerClient>(),
sp.GetRequiredService<TaskListViewModel>(),
sp.GetRequiredService<TaskDetailViewModel>(),

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
namespace ClaudeDo.Data;
public class ClaudeDoDbContext : DbContext
{
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<TagEntity> Tags => Set<TagEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly);
}
/// <summary>
/// Applies EF Core migrations and sets WAL mode. Safe for both fresh and existing databases.
/// Existing databases (created by the old schema.sql) have their tables but no
/// __EFMigrationsHistory — this method detects that case and baselines the initial
/// migration so EF skips re-creating tables that already exist.
/// </summary>
public static void MigrateAndConfigure(ClaudeDoDbContext db)
{
var conn = db.Database.GetDbConnection();
try
{
conn.Open();
// Set WAL FIRST, before migrations — prevents write-lock contention
// when UI and Worker start simultaneously.
using (var walCmd = conn.CreateCommand())
{
walCmd.CommandText = "PRAGMA journal_mode=wal;";
walCmd.ExecuteNonQuery();
}
// If the 'lists' table exists but __EFMigrationsHistory does not,
// this is a pre-EF database. Baseline the InitialCreate migration.
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'";
var hasLists = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'";
var hasHistory = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
if (hasLists && !hasHistory)
{
cmd.CommandText = """
CREATE TABLE "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260416064948_InitialCreate', '8.0.11');
""";
cmd.ExecuteNonQuery();
}
}
}
finally
{
conn.Close();
}
db.Database.Migrate();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -104,20 +104,34 @@ public sealed class GitService
using var proc = new Process { StartInfo = psi };
proc.Start();
// On cancellation: kill the git process tree. Killing closes the
// redirected pipes, which unblocks the ReadToEndAsync calls below
// and lets WaitForExitAsync return so the process is reaped.
// Without this, cancelling mid-git leaves zombie processes.
await using var ctr = ct.Register(() =>
{
try { proc.Kill(entireProcessTree: true); }
catch { /* already exited */ }
});
if (stdinData is not null)
{
await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct);
proc.StandardInput.Close();
}
var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct);
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
// Drain output without ct — pipes close when the process exits
// (whether naturally or via Kill above), so these always complete.
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
var stderrTask = proc.StandardError.ReadToEndAsync();
await proc.WaitForExitAsync(ct);
await proc.WaitForExitAsync(CancellationToken.None);
var stdout = await stdoutTask;
var stderr = await stderrTask;
ct.ThrowIfCancellationRequested();
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,171 +1,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,136 +151,38 @@ public sealed class TaskRepository
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT t.id, t.list_id, t.title, t.description, t.status, t.scheduled_for,
t.result, t.log_path, t.created_at, t.started_at, t.finished_at, t.commit_type,
t.model, t.system_prompt, t.agent_path
FROM tasks t
// 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
WHERE t.status = 'queued'
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now)
AND EXISTS (
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
AND (
EXISTS (
SELECT 1 FROM task_tags tt
JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.task_id = t.id AND tg.name = 'agent'
UNION
)
OR EXISTS (
SELECT 1 FROM list_tags lt
JOIN tags tg ON tg.id = lt.tag_id
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
)
)
ORDER BY t.created_at ASC
LIMIT 1
""";
cmd.Parameters.AddWithValue("@now", now.ToString("o"));
)
RETURNING *
""", nowStr).ToListAsync(ct);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadTask(reader);
return result.FirstOrDefault();
}
#endregion
#region Transitions
public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET log_path = @log_path WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@log_path", logPath);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'running', started_at = @started_at WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@started_at", startedAt.ToString("o"));
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'done', finished_at = @finished_at, result = @result WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o"));
cmd.Parameters.AddWithValue("@result", (object?)result ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? errorMarkdown, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'failed', finished_at = @finished_at, result = @result WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o"));
cmd.Parameters.AddWithValue("@result", (object?)errorMarkdown ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE tasks SET status = 'failed',
finished_at = @now,
result = '[stale] ' || @reason
WHERE status = 'running'
""";
cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o"));
cmd.Parameters.AddWithValue("@reason", reason);
return await cmd.ExecuteNonQueryAsync(ct);
}
#endregion
#region Helpers
private static void BindTask(SqliteCommand cmd, TaskEntity e)
{
cmd.Parameters.AddWithValue("@id", e.Id);
cmd.Parameters.AddWithValue("@list_id", e.ListId);
cmd.Parameters.AddWithValue("@title", e.Title);
cmd.Parameters.AddWithValue("@description", (object?)e.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("@status", ToDb(e.Status));
cmd.Parameters.AddWithValue("@scheduled_for", e.ScheduledFor.HasValue ? e.ScheduledFor.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@result", (object?)e.Result ?? DBNull.Value);
cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@commit_type", e.CommitType);
cmd.Parameters.AddWithValue("@model", (object?)e.Model ?? DBNull.Value);
cmd.Parameters.AddWithValue("@system_prompt", (object?)e.SystemPrompt ?? DBNull.Value);
cmd.Parameters.AddWithValue("@agent_path", (object?)e.AgentPath ?? DBNull.Value);
}
private static TaskEntity ReadTask(SqliteDataReader r) => new()
{
Id = r.GetString(0),
ListId = r.GetString(1),
Title = r.GetString(2),
Description = r.IsDBNull(3) ? null : r.GetString(3),
Status = FromDb(r.GetString(4)),
ScheduledFor = r.IsDBNull(5) ? null : DateTime.Parse(r.GetString(5)),
Result = r.IsDBNull(6) ? null : r.GetString(6),
LogPath = r.IsDBNull(7) ? null : r.GetString(7),
CreatedAt = DateTime.Parse(r.GetString(8)),
StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)),
FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)),
CommitType = r.GetString(11),
Model = r.IsDBNull(12) ? null : r.GetString(12),
SystemPrompt = r.IsDBNull(13) ? null : r.GetString(13),
AgentPath = r.IsDBNull(14) ? null : r.GetString(14),
};
#endregion
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,16 @@
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Allow Linux Gitea runners to publish this WPF project for win-x64; no-op on Windows. -->
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationIcon>ClaudeTaskSetup.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<!-- Embed icon so it is available via pack URI in WPF windows. -->
<Resource Include="ClaudeTaskSetup.ico" />
</ItemGroup>
<!-- Debug: asInvoker so Rider/VS can debug without elevation -->
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<ApplicationManifest>app.debug.manifest</ApplicationManifest>
@@ -19,10 +27,15 @@
</PropertyGroup>
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">
<!-- Framework-dependent: the WPF runtime pack isn't distributed for cross-compile
on Linux CI, which made self-contained bundles crash on startup with AV in the
apphost. Target machines already have the .NET 8 Desktop Runtime. -->
<SelfContained>false</SelfContained>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
<PublishTrimmed>false</PublishTrimmed>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<EnableCompressionInSingleFile>false</EnableCompressionInSingleFile>
</PropertyGroup>
<ItemGroup>

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

View File

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

View File

@@ -1,6 +1,8 @@
using System.Diagnostics;
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Installer.Steps;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Core;
@@ -15,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\.
@@ -36,6 +38,17 @@ public sealed class UninstallRunner
progress.Report("Unregistering service...");
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
// 3b) Remove Apps & Features registry entry (best-effort).
progress.Report("Removing Add/Remove Programs entry...");
try
{
Registry.LocalMachine.DeleteSubKeyTree(WriteUninstallRegistryStep.UninstallKeyPath, throwOnMissingSubKey: false);
}
catch (Exception ex)
{
progress.Report($"Warning: could not delete uninstall registry key: {ex.Message}");
}
// 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
progress.Report("Removing shortcuts...");
TryDeleteFile(Path.Combine(
@@ -54,7 +67,9 @@ public sealed class UninstallRunner
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
}
// 6) Delete ~/.todo-app (config + DB + logs) — user opted into full removal.
// 6) Delete ~/.todo-app (config + DB + logs) — only if user opted in.
if (removeAppData)
{
var appData = Paths.AppDataRoot();
if (Directory.Exists(appData))
{
@@ -62,6 +77,22 @@ public sealed class UninstallRunner
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),
// our own exe is still locked — schedule a cmd.exe trampoline to finish
// the deletion after this process exits. Best-effort: if this fails the
// user is left with an empty <uninstaller> folder which is harmless.
var runningExe = Environment.ProcessPath;
if (runningExe is not null
&& IsInsideDirectory(runningExe, _context.InstallDirectory)
&& Directory.Exists(_context.InstallDirectory))
{
progress.Report("Scheduling final cleanup after exit...");
TryScheduleTrampolineDelete(_context.InstallDirectory);
// The trampoline will finish the job — clear the residual failure entry for the install dir.
failures.RemoveAll(f => f.StartsWith("install dir"));
}
if (failures.Count > 0)
{
@@ -74,6 +105,37 @@ public sealed class UninstallRunner
return StepResult.Ok();
}
private static bool IsInsideDirectory(string filePath, string directory)
{
try
{
var full = Path.GetFullPath(filePath);
var dir = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
return full.StartsWith(dir, StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
private static void TryScheduleTrampolineDelete(string installDir)
{
try
{
var pid = Environment.ProcessId;
// Wait for this process to exit, then recursively remove the install dir.
// /B timeout avoids a visible window; ping as a portable sleep; rmdir /S /Q is silent.
var cmd = $"/C start \"\" /MIN cmd /C \"ping 127.0.0.1 -n 3 >nul & rmdir /S /Q \"\"{installDir}\"\"\"";
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = cmd,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
});
}
catch { /* best effort */ }
}
/// <summary>
/// Guards against catastrophic recursive-delete paths. The install dir must be
/// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/").

View File

@@ -54,6 +54,7 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
Steps.Add(new StepViewModel("Initialize Database"));
Steps.Add(new StepViewModel("Register Windows Service"));
Steps.Add(new StepViewModel("Create Shortcuts"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
Steps.Add(new StepViewModel("Write Install Manifest"));
}
return Task.CompletedTask;
@@ -82,7 +83,21 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
step.Status = p.Status;
if (p.Message is not null)
{
// Messages starting with "\r" overwrite the previous line (live progress).
if (p.Message.StartsWith('\r'))
{
var line = p.Message[1..];
if (step.Messages.Count > 0 && step.Messages[^1].StartsWith(" "))
step.Messages[^1] = line;
else
step.Messages.Add(line);
}
else
{
step.Messages.Add(p.Message);
}
}
if (p.Status is StepStatus.Running && !step.IsExpanded)
step.IsExpanded = true;

View File

@@ -44,9 +44,18 @@ public sealed class DownloadAndExtractStep : IInstallStep
var zipPath = Path.Combine(scratchDir, zipAsset.Name);
var checksumPath = Path.Combine(scratchDir, "checksums.txt");
progress.Report($"Downloading {zipAsset.Name} ({zipAsset.Size / (1024 * 1024)} MB)...");
var totalMb = zipAsset.Size / (1024 * 1024);
progress.Report($"Downloading {zipAsset.Name} ({totalMb} MB)...");
long lastReportedMb = -1;
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
new Progress<long>(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")),
new Progress<long>(b =>
{
var mb = b / (1024 * 1024);
if (mb == lastReportedMb) return;
lastReportedMb = mb;
// Leading "\r" tells the UI to overwrite the previous line instead of appending.
progress.Report($"\r {mb} / {totalMb} MB downloaded");
}),
ct);
progress.Report("Downloading checksums...");
@@ -61,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);
@@ -75,11 +89,19 @@ public sealed class DownloadAndExtractStep : IInstallStep
}
catch (Exception ex)
{
// Roll back to previous binaries.
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
if (Directory.Exists(appBak)) Directory.Move(appBak, appDest);
if (Directory.Exists(workerBak)) Directory.Move(workerBak, workerDest);
return StepResult.Fail(
$"Extraction failed after old binaries were removed: {ex.Message}. " +
"Your install directory may be incomplete. Re-run the installer to retry.");
$"Extraction failed; previous binaries have been restored: {ex.Message}.");
}
// Success — drop stash.
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true);
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
return StepResult.Ok();
}

View File

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

View File

@@ -23,18 +23,39 @@ public sealed class RegisterServiceStep : IInstallStep
progress.Report("Removing existing service registration (if any)...");
await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
// Wait for the service to actually disappear from SCM. `sc delete` returns
// immediately but the service stays "marked for deletion" until every open
// handle (services.msc, Task Manager, a prior sc query process) is closed.
// Poll up to 30s — then fail with actionable guidance if it's still there.
progress.Report("Waiting for prior service registration to clear...");
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (queryExit, _) = await RunSc($"query {ServiceName}", ctx, progress, ct, ignoreErrors: true);
if (queryExit != 0) break; // service no longer registered — good
if (i == 29)
return StepResult.Fail(
$"Service '{ServiceName}' is marked for deletion but hasn't cleared after 30s. " +
"Close any open Services console (services.msc), Task Manager Services tab, or " +
"Event Viewer showing the service, then retry. A reboot will also clear it.");
await Task.Delay(1000, ct);
}
// Create service
var startType = ctx.AutoStart ? "auto" : "demand";
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);
if (exitCode == 1072)
return StepResult.Fail(
$"Service '{ServiceName}' is still marked for deletion. " +
"Close services.msc / Task Manager / Event Viewer and retry, or reboot.");
if (exitCode != 0)
return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
@@ -46,15 +67,6 @@ public sealed class RegisterServiceStep : IInstallStep
if (failExit != 0)
progress.Report($"Warning: failed to set restart policy (exit {failExit})");
// Start service if auto-start
if (ctx.AutoStart)
{
progress.Report("Starting service...");
var (startExit, _) = await RunSc($"start {ServiceName}", ctx, progress, ct);
if (startExit != 0)
progress.Report("Warning: service created but failed to start. You may need to start it manually.");
}
return StepResult.Ok();
}

View File

@@ -27,6 +27,12 @@ public sealed class StopServiceStep : IInstallStep
}
var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
// 1062 = ERROR_SERVICE_NOT_ACTIVE — registered but not running, treat as already stopped.
if (stopExit == 1062)
{
progress.Report("Service was registered but not running.");
return StepResult.Ok();
}
if (stopExit != 0)
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");

View File

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

View File

@@ -0,0 +1,81 @@
using System.IO;
using ClaudeDo.Installer.Core;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Steps;
/// <summary>
/// Registers ClaudeDo under <c>HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo</c>
/// so it shows up in Windows "Apps &amp; Features" / "Programs and Features".
/// Also copies the running installer into the install directory so there is an exe
/// for UninstallString to reference after the temp-extracted single-file bundle is gone.
/// </summary>
public sealed class WriteUninstallRegistryStep : IInstallStep
{
internal const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo";
public string Name => "Register in Add/Remove Programs";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var uninstallDir = Path.Combine(ctx.InstallDirectory, "uninstaller");
Directory.CreateDirectory(uninstallDir);
var targetExe = Path.Combine(uninstallDir, "ClaudeDo.Installer.exe");
// Copy the running installer so Apps & Features has a stable exe to launch —
// the single-file temp extract is gone once this process exits.
var sourceExe = Environment.ProcessPath
?? throw new InvalidOperationException("Cannot resolve running installer path.");
try
{
progress.Report("Copying uninstaller binary...");
File.Copy(sourceExe, targetExe, overwrite: true);
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
}
progress.Report("Writing Add/Remove Programs entry...");
try
{
using var key = Registry.LocalMachine.CreateSubKey(UninstallKeyPath, writable: true);
if (key is null)
return StepResult.Fail("Could not open uninstall registry key (permission denied?).");
key.SetValue("DisplayName", "ClaudeDo", RegistryValueKind.String);
key.SetValue("DisplayVersion", ctx.InstallerVersion ?? "0.0.0", RegistryValueKind.String);
key.SetValue("Publisher", "Mika Kuns", RegistryValueKind.String);
key.SetValue("InstallLocation", ctx.InstallDirectory, RegistryValueKind.String);
key.SetValue("UninstallString", $"\"{targetExe}\"", RegistryValueKind.String);
key.SetValue("DisplayIcon", targetExe, RegistryValueKind.String);
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
// Best-effort install size (KB) — scan install dir.
try
{
var sizeKb = (int)(DirectorySizeBytes(ctx.InstallDirectory) / 1024);
key.SetValue("EstimatedSize", sizeKb, RegistryValueKind.DWord);
}
catch { /* best-effort only */ }
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to write uninstall registry: {ex.Message}");
}
await Task.CompletedTask;
return StepResult.Ok();
}
private static long DirectorySizeBytes(string path)
{
long total = 0;
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
try { total += new FileInfo(file).Length; } catch { /* ignore */ }
}
return total;
}
}

View File

@@ -184,6 +184,34 @@
</Setter>
</Style>
<!-- ComboBox toggle button (dropdown arrow chrome) -->
<ControlTemplate x:Key="ComboBoxToggleButtonTemplate" TargetType="ToggleButton">
<Border x:Name="Bd"
Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1"
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="20"/>
</Grid.ColumnDefinitions>
<Path Grid.Column="1"
HorizontalAlignment="Center" VerticalAlignment="Center"
Fill="{StaticResource TextSecondaryBrush}"
Data="M 0 0 L 4 4 L 8 0 Z"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<!-- ComboBox -->
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
@@ -191,6 +219,71 @@
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton x:Name="ToggleButton"
Template="{StaticResource ComboBoxToggleButtonTemplate}"
Focusable="False"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
ClickMode="Press"/>
<ContentPresenter x:Name="ContentSite"
IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center" HorizontalAlignment="Left"
TextElement.Foreground="{StaticResource TextPrimaryBrush}"/>
<Popup x:Name="Popup"
Placement="Bottom"
IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True" Focusable="False"
PopupAnimation="Slide">
<Border x:Name="DropDownBorder"
Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1"
CornerRadius="4"
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="{TemplateBinding MaxDropDownHeight}">
<ScrollViewer SnapsToDevicePixels="True">
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ComboBoxItem — dark dropdown rows -->
<Style TargetType="ComboBoxItem">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionHoverBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- CheckBox -->

View File

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

View File

@@ -3,9 +3,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
Title="ClaudeDo Settings"
Icon="/ClaudeTaskSetup.ico"
Width="720" Height="520"
MinWidth="620" MinHeight="460"
WindowStartupLocation="CenterScreen"
Background="{StaticResource WindowBgBrush}"
Foreground="{StaticResource TextPrimaryBrush}"
FontFamily="Segoe UI"
FontSize="13"
d:DataContext="{d:DesignInstance views:SettingsViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -64,6 +69,7 @@
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Status message / version label -->
@@ -83,14 +89,17 @@
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Uninstall" Margin="0,0,8,0"
<CheckBox Grid.Column="1" IsChecked="{Binding RemoveAppData}"
Content="Remove user data (tasks, logs, configs in ~/.todo-app)"
Margin="0,0,12,0" VerticalAlignment="Center"/>
<Button Grid.Column="2" Content="Uninstall" Margin="0,0,8,0"
Command="{Binding UninstallCommand}"/>
<Button Grid.Column="2" Content="Repair" Margin="0,0,8,0"
<Button Grid.Column="3" Content="Repair" Margin="0,0,8,0"
Command="{Binding RepairCommand}"/>
<Button Grid.Column="3" Content="Save" Margin="0,0,8,0"
<Button Grid.Column="4" Content="Save" Margin="0,0,8,0"
Command="{Binding SaveCommand}"
Style="{StaticResource AccentButton}"/>
<Button Grid.Column="4" Content="Close"
<Button Grid.Column="5" Content="Close"
Command="{Binding CloseCommand}"/>
</Grid>
</Border>

View File

@@ -3,9 +3,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
Title="ClaudeDo Installer"
Icon="/ClaudeTaskSetup.ico"
Width="720" Height="520"
MinWidth="620" MinHeight="460"
WindowStartupLocation="CenterScreen"
Background="{StaticResource WindowBgBrush}"
Foreground="{StaticResource TextPrimaryBrush}"
FontFamily="Segoe UI"
FontSize="13"
d:DataContext="{d:DesignInstance views:WizardViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
@@ -9,18 +10,15 @@ using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskDetailViewModel : ViewModelBase
{
private readonly TaskRepository _taskRepo;
private readonly WorktreeRepository _worktreeRepo;
private readonly ListRepository _listRepo;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly WorkerClient _worker;
private readonly TagRepository _tagRepo;
private readonly SubtaskRepository _subtaskRepo;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string? _description;
@@ -55,20 +53,18 @@ public partial class TaskDetailViewModel : ViewModelBase
private string? _taskId;
private string? _listId;
private bool _isLoading;
// Cancels an in-flight LoadAsync when a new TaskUpdated event arrives
// before the previous load finished — prevents torn state on _taskId,
// Subtasks, Tags, etc.
private CancellationTokenSource? _loadCts;
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;
@@ -79,16 +75,51 @@ public partial class TaskDetailViewModel : ViewModelBase
public async Task LoadAsync(string taskId)
{
// Cancel any in-flight load so rapid TaskUpdated events don't race
// on _taskId / Subtasks / Tags. The newest caller wins.
var oldCts = _loadCts;
var cts = new CancellationTokenSource();
_loadCts = cts;
oldCts?.Cancel();
oldCts?.Dispose();
var ct = cts.Token;
_taskId = taskId;
HasWorktree = false;
WorktreeState = "";
BranchName = null;
DiffStat = null;
WorktreePath = null;
OnPropertyChanged(nameof(CanWorktreeAction));
LiveText = "";
_formatter = new StreamLineFormatter();
var task = await _taskRepo.GetByIdAsync(taskId);
try
{
TaskEntity? task;
List<TagEntity> tags;
List<SubtaskEntity> subtasks;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
task = await taskRepo.GetByIdAsync(taskId, ct);
if (task is null) return;
ct.ThrowIfCancellationRequested();
tags = await taskRepo.GetTagsAsync(taskId, ct);
ct.ThrowIfCancellationRequested();
var subtaskRepo = new SubtaskRepository(context);
subtasks = await subtaskRepo.GetByTaskIdAsync(taskId, ct);
}
ct.ThrowIfCancellationRequested();
if (AvailableAgents.Count == 0)
{
var agents = await _worker.GetAgentsAsync();
ct.ThrowIfCancellationRequested();
AvailableAgents.AddRange(agents);
OnPropertyChanged(nameof(AvailableAgents));
}
@@ -106,7 +137,7 @@ public partial class TaskDetailViewModel : ViewModelBase
&& File.Exists(task.LogPath))
{
_formatter = new StreamLineFormatter();
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath));
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath), ct);
}
StatusText = task.Status.ToString().ToLowerInvariant();
StatusChoice = task.Status.ToString();
@@ -132,12 +163,12 @@ public partial class TaskDetailViewModel : ViewModelBase
}
Tags.Clear();
var tags = await _taskRepo.GetTagsAsync(taskId);
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);
foreach (var s in subtasks)
{
var vm = SubtaskItemViewModel.From(s);
@@ -152,12 +183,19 @@ public partial class TaskDetailViewModel : ViewModelBase
await LoadWorktreeAsync(taskId);
}
catch (OperationCanceledException)
{
// Superseded by a newer LoadAsync — nothing to do.
}
}
public async Task SaveAsync()
{
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;
@@ -172,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);
}
@@ -183,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);
@@ -199,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);
}
@@ -217,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);
@@ -227,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);
}
@@ -236,16 +286,28 @@ public partial class TaskDetailViewModel : ViewModelBase
{
if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return;
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
await _subtaskRepo.UpdateAsync(new SubtaskEntity
try
{
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)
{
// async void must never throw — surface via Debug.
Debug.WriteLine($"[TaskDetailViewModel] Subtask update failed for {vm.Id}: {ex}");
}
}
public void SetAgentFromPath(string path)
{
@@ -261,6 +323,11 @@ public partial class TaskDetailViewModel : ViewModelBase
public void Clear()
{
// Cancel any load in flight so it doesn't resurrect state after Clear.
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = null;
_taskId = null;
_listId = null;
Title = "";
@@ -284,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)
{
@@ -317,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
{
@@ -334,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);
}
@@ -356,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);
}
@@ -369,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);
}
@@ -408,12 +521,28 @@ public partial class TaskDetailViewModel : ViewModelBase
private async void OnWorktreeUpdated(string taskId)
{
if (taskId != _taskId) return;
try
{
await LoadWorktreeAsync(taskId);
}
catch (Exception ex)
{
// async void must never throw.
Debug.WriteLine($"[TaskDetailViewModel] OnWorktreeUpdated failed for {taskId}: {ex}");
}
}
private async void OnTaskUpdated(string taskId)
{
if (taskId != _taskId) return;
try
{
await LoadAsync(taskId);
}
catch (Exception ex)
{
// async void must never throw.
Debug.WriteLine($"[TaskDetailViewModel] OnTaskUpdated failed for {taskId}: {ex}");
}
}
}

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@
x:Class="ClaudeDo.Ui.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="ClaudeDo"
Icon="avares://ClaudeDo.App/Assets/ClaudeTask.ico"
MinWidth="800" MinHeight="500"
KeyDown="OnGlobalKeyDown">

View File

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

View File

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

View File

@@ -6,6 +6,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
</ItemGroup>
<PropertyGroup>

View File

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

View File

@@ -5,23 +5,20 @@ using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Services;
using Microsoft.EntityFrameworkCore;
var cfg = WorkerConfig.Load();
var builder = WebApplication.CreateBuilder(args);
// Initialize DB schema before the host starts accepting connections.
var dbFactory = new SqliteConnectionFactory(cfg.DbPath);
SchemaInitializer.Apply(dbFactory);
// When launched by the Windows SCM, speak the Service Control Protocol so SCM
// doesn't think we crashed (~30s timeout). No-op when running interactively.
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
builder.Services.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();
@@ -47,6 +44,12 @@ builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
ClaudeDoDbContext.MigrateAndConfigure(
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
}
app.MapHub<WorkerHub>("/hub");
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",

View File

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

View File

@@ -45,6 +45,9 @@ public sealed class ClaudeProcess : IClaudeProcess
var analyzer = new StreamAnalyzer();
var lastStderr = new StringBuilder();
// On cancellation: kill the tree. Killing closes the redirected pipes,
// which unblocks the ReadLineAsync loops below (which run without ct
// so they reliably drain instead of hanging on cancellation).
await using var ctr = ct.Register(() =>
{
try { process.Kill(entireProcessTree: true); }
@@ -53,26 +56,30 @@ public sealed class ClaudeProcess : IClaudeProcess
var stdoutTask = Task.Run(async () =>
{
while (await process.StandardOutput.ReadLineAsync(ct) is { } line)
while (await process.StandardOutput.ReadLineAsync() is { } line)
{
if (string.IsNullOrEmpty(line)) continue;
await onStdoutLine(line);
analyzer.ProcessLine(line);
}
}, ct);
});
var stderrTask = Task.Run(async () =>
{
while (await process.StandardError.ReadLineAsync(ct) is { } line)
while (await process.StandardError.ReadLineAsync() is { } line)
{
if (string.IsNullOrEmpty(line)) continue;
lastStderr.AppendLine(line);
await onStdoutLine($"[stderr] {line}");
}
}, ct);
});
await Task.WhenAll(stdoutTask, stderrTask);
await process.WaitForExitAsync(ct);
await process.WaitForExitAsync(CancellationToken.None);
// If we were asked to cancel, surface that to the caller now that
// the process is fully reaped.
ct.ThrowIfCancellationRequested();
var exitCode = process.ExitCode;
var streamResult = analyzer.GetResult();

View File

@@ -1,18 +1,16 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Runner;
public sealed class TaskRunner
{
private readonly IClaudeProcess _claude;
private readonly TaskRepository _taskRepo;
private readonly TaskRunRepository _runRepo;
private readonly ListRepository _listRepo;
private readonly WorktreeRepository _wtRepo;
private readonly SubtaskRepository _subtaskRepo;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly HubBroadcaster _broadcaster;
private readonly WorktreeManager _wtManager;
private readonly ClaudeArgsBuilder _argsBuilder;
@@ -21,11 +19,7 @@ public sealed class TaskRunner
public TaskRunner(
IClaudeProcess claude,
TaskRepository taskRepo,
TaskRunRepository runRepo,
ListRepository listRepo,
WorktreeRepository wtRepo,
SubtaskRepository subtaskRepo,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
HubBroadcaster broadcaster,
WorktreeManager wtManager,
ClaudeArgsBuilder argsBuilder,
@@ -33,11 +27,7 @@ public sealed class TaskRunner
ILogger<TaskRunner> logger)
{
_claude = claude;
_taskRepo = taskRepo;
_runRepo = runRepo;
_listRepo = listRepo;
_wtRepo = wtRepo;
_subtaskRepo = subtaskRepo;
_dbFactory = dbFactory;
_broadcaster = broadcaster;
_wtManager = wtManager;
_argsBuilder = argsBuilder;
@@ -49,12 +39,24 @@ public sealed class TaskRunner
{
try
{
var list = await _listRepo.GetByIdAsync(task.ListId, ct);
ListEntity? list;
ListConfigEntity? listConfig;
List<SubtaskEntity> subtasks;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(task.ListId, ct);
if (list is null)
{
await MarkFailed(task.Id, slot, "List not found.");
return;
}
listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
var subtaskRepo = new SubtaskRepository(context);
subtasks = await subtaskRepo.GetByTaskIdAsync(task.Id, ct);
}
// Determine working directory: worktree or sandbox.
WorktreeContext? wtCtx = null;
@@ -81,7 +83,6 @@ public sealed class TaskRunner
}
// Resolve config: task overrides > list config > null.
var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
var resolvedConfig = new ClaudeRunConfig(
Model: task.Model ?? listConfig?.Model ?? "claude-sonnet-4-6",
SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
@@ -90,11 +91,14 @@ public sealed class TaskRunner
);
var now = DateTime.UtcNow;
await _taskRepo.MarkRunningAsync(task.Id, now, ct);
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
await taskRepo.MarkRunningAsync(task.Id, now, ct);
}
await _broadcaster.TaskStarted(slot, task.Id, now);
// Build prompt.
var subtasks = await _subtaskRepo.GetByTaskIdAsync(task.Id, ct);
var sb = new System.Text.StringBuilder(task.Title);
if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim());
if (subtasks.Count > 0)
@@ -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)
TaskEntity task;
TaskRunEntity lastRun;
ListEntity list;
ListConfigEntity? listConfig;
WorktreeEntity? worktree;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
task = await taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
var lastRun = await _runRepo.GetLatestByTaskIdAsync(taskId, ct)
var runRepo = new TaskRunRepository(context);
lastRun = await runRepo.GetLatestByTaskIdAsync(taskId, ct)
?? throw new InvalidOperationException("No previous run to continue.");
if (lastRun.SessionId is null)
throw new InvalidOperationException("Previous run has no session ID — cannot resume.");
var list = await _listRepo.GetByIdAsync(task.ListId, ct)
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
var wtRepo = new WorktreeRepository(context);
worktree = await wtRepo.GetByTaskIdAsync(taskId, ct);
}
var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
var resolvedConfig = new ClaudeRunConfig(
Model: task.Model ?? listConfig?.Model,
SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
@@ -178,7 +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,12 +246,21 @@ 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);
await using var logWriter = new LogWriter(logPath);
try
{
var result = await _claude.RunAsync(
arguments,
prompt,
@@ -243,7 +272,9 @@ public sealed class TaskRunner
},
ct);
// Update the run record with results.
// Update the run record with results. Use CancellationToken.None:
// this is a terminal write that must always complete, even if the
// caller's token is already cancelled.
run.SessionId = result.SessionId;
run.ResultMarkdown = result.ResultMarkdown;
run.StructuredOutputJson = result.StructuredOutputJson;
@@ -253,13 +284,41 @@ public sealed class TaskRunner
run.TokensIn = result.TokensIn;
run.TokensOut = result.TokensOut;
run.FinishedAt = DateTime.UtcNow;
await _runRepo.UpdateAsync(run, ct);
// Update denormalized fields on the task.
await _taskRepo.SetLogPathAsync(taskId, logPath, ct);
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;
}
catch (OperationCanceledException)
{
// Ensure the run row is completed so ContinueAsync / inspection
// isn't left staring at a null session_id / finished_at.
run.ErrorMarkdown = "Cancelled.";
run.ExitCode = -1;
run.FinishedAt = DateTime.UtcNow;
try
{
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)
{
_logger.LogError(updateEx, "Failed to finalize cancelled run {RunId} for task {TaskId}", runId, taskId);
}
throw;
}
}
private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct)
{
@@ -270,8 +329,15 @@ public sealed class TaskRunner
await _broadcaster.WorktreeUpdated(task.Id);
}
// Terminal DB write uses CancellationToken.None so the task status
// 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, ct);
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);
@@ -279,8 +345,12 @@ public sealed class TaskRunner
private async Task HandleFailure(string taskId, string slot, RunResult result)
{
// 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);
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);
}
@@ -290,7 +360,10 @@ public sealed class TaskRunner
try
{
var now = DateTime.UtcNow;
await _taskRepo.MarkFailedAsync(taskId, now, error);
// Terminal write — never cancel.
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
await taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
await _broadcaster.TaskFinished(slot, taskId, "failed", now);
await _broadcaster.TaskUpdated(taskId);
}

View File

@@ -1,7 +1,9 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Runner;
@@ -10,14 +12,14 @@ public sealed record WorktreeContext(string WorktreePath, string BranchName, str
public sealed class WorktreeManager
{
private readonly GitService _git;
private readonly WorktreeRepository _wtRepo;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerConfig _cfg;
private readonly ILogger<WorktreeManager> _logger;
public WorktreeManager(GitService git, WorktreeRepository wtRepo, WorkerConfig cfg, ILogger<WorktreeManager> logger)
public WorktreeManager(GitService git, IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerConfig cfg, ILogger<WorktreeManager> logger)
{
_git = git;
_wtRepo = wtRepo;
_dbFactory = dbFactory;
_cfg = cfg;
_logger = logger;
}
@@ -31,8 +33,10 @@ public sealed class WorktreeManager
throw new InvalidOperationException($"working_dir is not a git repository: {workingDir}");
var baseCommit = await _git.RevParseHeadAsync(workingDir, ct);
var shortId = task.Id.Length >= 8 ? task.Id[..8] : task.Id;
var branchName = $"claudedo/{shortId}";
// Use the full task id (dashes stripped) in the branch name so
// two GUIDs sharing an 8-char prefix cannot collide on the same branch.
var idForBranch = task.Id.Replace("-", "");
var branchName = $"claudedo/{idForBranch}";
var slug = CommitMessageBuilder.ToSlug(list.Name);
var worktreePath = _cfg.WorktreeRootStrategy.Equals("central", StringComparison.OrdinalIgnoreCase)
@@ -48,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,
@@ -85,7 +91,9 @@ public sealed class WorktreeManager
var head = await _git.RevParseHeadAsync(ctx.WorktreePath, ct);
var diffStat = await _git.DiffStatAsync(ctx.WorktreePath, ctx.BaseCommit, head, ct);
await _wtRepo.UpdateHeadAsync(task.Id, head, diffStat, ct);
using var context = _dbFactory.CreateDbContext();
var wtRepo = new WorktreeRepository(context);
await wtRepo.UpdateHeadAsync(task.Id, head, diffStat, ct);
_logger.LogInformation("Committed changes for task {TaskId}: {Head}", task.Id, head);
return true;

View File

@@ -1,7 +1,9 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Services;
@@ -14,7 +16,7 @@ public sealed class QueueSlotState
public sealed class QueueService : BackgroundService
{
private readonly TaskRepository _taskRepo;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly TaskRunner _runner;
private readonly WorkerConfig _cfg;
private readonly ILogger<QueueService> _logger;
@@ -26,12 +28,12 @@ public sealed class QueueService : BackgroundService
private readonly SemaphoreSlim _wakeSignal = new(0, 1);
public QueueService(
TaskRepository taskRepo,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
TaskRunner runner,
WorkerConfig cfg,
ILogger<QueueService> logger)
{
_taskRepo = taskRepo;
_dbFactory = dbFactory;
_runner = runner;
_cfg = cfg;
_logger = logger;
@@ -56,44 +58,60 @@ public sealed class QueueService : BackgroundService
public async Task RunNow(string taskId)
{
var task = await _taskRepo.GetByIdAsync(taskId);
if (task is null)
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);
}
}
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);
}
@@ -142,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)
@@ -152,9 +175,12 @@ 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.
}, TaskScheduler.Default);
}
@@ -172,16 +198,25 @@ public sealed class QueueService : BackgroundService
_logger.LogInformation("QueueService stopping");
}
private async Task RunInSlotAsync(TaskEntity task, string slot, CancellationToken ct)
private async Task RunInSlotAsync(string taskId, string slot, CancellationToken ct)
{
try
{
_logger.LogInformation("Starting task {TaskId} in {Slot} slot", task.Id, slot);
_logger.LogInformation("Starting task {TaskId} in {Slot} slot", taskId, slot);
TaskEntity task;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
task = await taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
}
await _runner.RunAsync(task, slot, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Slot runner error for task {TaskId}", task.Id);
_logger.LogError(ex, "Slot runner error for task {TaskId}", taskId);
}
}

View File

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

View File

@@ -7,6 +7,8 @@
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Allow Linux Gitea runners to build this Windows-targeted project; no-op on Windows. -->
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
<ItemGroup>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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