17 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
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
24 changed files with 534 additions and 112 deletions

View File

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

View File

@@ -30,35 +30,48 @@ public class ClaudeDoDbContext : DbContext
/// </summary> /// </summary>
public static void MigrateAndConfigure(ClaudeDoDbContext db) public static void MigrateAndConfigure(ClaudeDoDbContext db)
{ {
// If the 'lists' table exists but __EFMigrationsHistory does not,
// this is a pre-EF database. Baseline the InitialCreate migration.
var conn = db.Database.GetDbConnection(); var conn = db.Database.GetDbConnection();
conn.Open(); try
using (var cmd = conn.CreateCommand())
{ {
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'"; conn.Open();
var hasLists = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'"; // Set WAL FIRST, before migrations — prevents write-lock contention
var hasHistory = Convert.ToInt64(cmd.ExecuteScalar()) > 0; // when UI and Worker start simultaneously.
using (var walCmd = conn.CreateCommand())
if (hasLists && !hasHistory)
{ {
// Create the history table and mark InitialCreate as applied. walCmd.CommandText = "PRAGMA journal_mode=wal;";
cmd.CommandText = """ walCmd.ExecuteNonQuery();
CREATE TABLE "__EFMigrationsHistory" ( }
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL // If the 'lists' table exists but __EFMigrationsHistory does not,
); // this is a pre-EF database. Baseline the InitialCreate migration.
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") using (var cmd = conn.CreateCommand())
VALUES ('20260416064948_InitialCreate', '8.0.11'); {
"""; cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'";
cmd.ExecuteNonQuery(); 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();
}
} }
} }
conn.Close(); finally
{
conn.Close();
}
db.Database.Migrate(); db.Database.Migrate();
db.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL");
} }
} }

View File

@@ -46,7 +46,8 @@ public sealed class ListRepository
public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default) public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
{ {
var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, 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); var tag = await _context.Tags.FindAsync([tagId], ct);
if (tag is not null && !list.Tags.Any(t => t.Id == tagId)) if (tag is not null && !list.Tags.Any(t => t.Id == tagId))
{ {
@@ -57,7 +58,8 @@ public sealed class ListRepository
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default) public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
{ {
var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, 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); var tag = list.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null) if (tag is not null)
{ {

View File

@@ -104,7 +104,8 @@ public sealed class TaskRepository
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default) public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
{ {
var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, 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); var tag = await _context.Tags.FindAsync([tagId], ct);
if (tag is not null && !task.Tags.Any(t => t.Id == tagId)) if (tag is not null && !task.Tags.Any(t => t.Id == tagId))
{ {
@@ -115,7 +116,8 @@ public sealed class TaskRepository
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default) public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
{ {
var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, 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); var tag = task.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null) if (tag is not null)
{ {

View File

@@ -110,14 +110,16 @@ public partial class App : Application
sc.AddSingleton<IInstallStep, WriteConfigStep>(); sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>(); sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<IInstallStep, RegisterServiceStep>(); sc.AddSingleton<IInstallStep, RegisterServiceStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartServiceStep>());
sc.AddSingleton<IInstallStep, CreateShortcutsStep>(); sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>(); sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
sc.AddSingleton<WriteInstallManifestStep>(); sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<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. // Pulled by Update flow + Repair/Uninstall.
sc.AddSingleton<StopServiceStep>(); sc.AddSingleton<StopServiceStep>();
// StartServiceStep is also registered as IInstallStep above (fresh-install pipeline).
sc.AddSingleton<StartServiceStep>(); sc.AddSingleton<StartServiceStep>();
// Runners // Runners

View File

@@ -22,7 +22,7 @@ public sealed class InstallContext
public int SignalRPort { get; set; } = 47_821; public int SignalRPort { get; set; } = 47_821;
public int QueueBackstopIntervalMs { get; set; } = 30_000; public int QueueBackstopIntervalMs { get; set; } = 30_000;
public string ClaudeBin { get; set; } = "claude"; 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 bool AutoStart { get; set; } = true;
public int RestartDelayMs { get; set; } = 5000; public int RestartDelayMs { get; set; } = 5000;

View File

@@ -17,7 +17,7 @@ public sealed class UninstallRunner
_stopService = stopService; _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. // 1) Validate install dir up front — refuse obviously unsafe paths.
// Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\. // Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\.
@@ -67,13 +67,16 @@ public sealed class UninstallRunner
failures.Add($"install dir ({_context.InstallDirectory}): {err}"); 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.
var appData = Paths.AppDataRoot(); if (removeAppData)
if (Directory.Exists(appData))
{ {
progress.Report($"Deleting {appData}..."); var appData = Paths.AppDataRoot();
if (!TryDeleteDir(appData, out var err)) if (Directory.Exists(appData))
failures.Add($"app data ({appData}): {err}"); {
progress.Report($"Deleting {appData}...");
if (!TryDeleteDir(appData, out var err))
failures.Add($"app data ({appData}): {err}");
}
} }
// 7) If we were launched from inside the install dir (Apps & Features case), // 7) If we were launched from inside the install dir (Apps & Features case),

View File

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

View File

@@ -46,10 +46,9 @@ public sealed class RegisterServiceStep : IInstallStep
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}"; var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
if (ctx.ServiceAccount == "CurrentUser") if (ctx.ServiceAccount == "CurrentUser")
{ return StepResult.Fail(
var username = Environment.UserName; "Service cannot run as Current User without a password. " +
createArgs += $" obj= \".\\{username}\""; "Select 'Local System' or extend ServicePage to capture a password.");
}
progress.Report("Creating service..."); progress.Report("Creating service...");
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct); var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
@@ -68,15 +67,6 @@ public sealed class RegisterServiceStep : IInstallStep
if (failExit != 0) if (failExit != 0)
progress.Report($"Warning: failed to set restart policy (exit {failExit})"); 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(); return StepResult.Ok();
} }

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps; namespace ClaudeDo.Installer.Steps;
@@ -10,13 +11,15 @@ public sealed class WriteConfigStep : IInstallStep
{ {
try 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 var workerCfg = new InstallerWorkerConfig
{ {
DbPath = ctx.DbPath, DbPath = Paths.Expand(ctx.DbPath),
SandboxRoot = ctx.SandboxRoot, SandboxRoot = Paths.Expand(ctx.SandboxRoot),
LogRoot = ctx.LogRoot, LogRoot = Paths.Expand(ctx.LogRoot),
WorktreeRootStrategy = ctx.WorktreeRootStrategy, WorktreeRootStrategy = ctx.WorktreeRootStrategy,
CentralWorktreeRoot = ctx.CentralWorktreeRoot, CentralWorktreeRoot = Paths.Expand(ctx.CentralWorktreeRoot),
QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs, QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs,
SignalRPort = ctx.SignalRPort, SignalRPort = ctx.SignalRPort,
ClaudeBin = ctx.ClaudeBin, ClaudeBin = ctx.ClaudeBin,
@@ -26,7 +29,7 @@ public sealed class WriteConfigStep : IInstallStep
var uiCfg = new InstallerAppSettings var uiCfg = new InstallerAppSettings
{ {
DbPath = ctx.UiDbPath, DbPath = Paths.Expand(ctx.UiDbPath),
SignalRUrl = ctx.SignalRUrl, SignalRUrl = ctx.SignalRUrl,
}; };
uiCfg.Save(); uiCfg.Save();

View File

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

View File

@@ -69,6 +69,7 @@
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Status message / version label --> <!-- Status message / version label -->
@@ -88,14 +89,17 @@
</TextBlock> </TextBlock>
</StackPanel> </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}"/> 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}"/> 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}" Command="{Binding SaveCommand}"
Style="{StaticResource AccentButton}"/> Style="{StaticResource AccentButton}"/>
<Button Grid.Column="4" Content="Close" <Button Grid.Column="5" Content="Close"
Command="{Binding CloseCommand}"/> Command="{Binding CloseCommand}"/>
</Grid> </Grid>
</Border> </Border>

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using Avalonia.Threading; using Avalonia.Threading;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
@@ -208,9 +209,13 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
ActiveTasks.Add(new ActiveTask(a.Slot, a.TaskId, a.StartedAt)); 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() public void InitForCreate()
{ {
_tcs = new TaskCompletionSource<ListEntity?>();
_editId = null; _editId = null;
_createdAt = DateTime.UtcNow; _createdAt = DateTime.UtcNow;
WindowTitle = "New List"; WindowTitle = "New List";
@@ -61,6 +62,7 @@ public partial class ListEditorViewModel : ViewModelBase
public void InitForEdit(ListEntity entity, ListConfigEntity? config) public void InitForEdit(ListEntity entity, ListConfigEntity? config)
{ {
_tcs = new TaskCompletionSource<ListEntity?>();
_editId = entity.Id; _editId = entity.Id;
_createdAt = entity.CreatedAt; _createdAt = entity.CreatedAt;
Name = entity.Name; Name = entity.Name;
@@ -119,9 +121,5 @@ public partial class ListEditorViewModel : ViewModelBase
_tcs.TrySetResult(null); _tcs.TrySetResult(null);
} }
public Task<ListEntity?> ShowAndWaitAsync() public Task<ListEntity?> ShowAndWaitAsync() => _tcs.Task;
{
_tcs = new TaskCompletionSource<ListEntity?>();
return _tcs.Task;
}
} }

View File

@@ -13,7 +13,7 @@ using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.ViewModels;
public partial class MainWindowViewModel : ViewModelBase public partial class MainWindowViewModel : ViewModelBase, IDisposable
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker; private readonly WorkerClient _worker;
@@ -27,6 +27,8 @@ public partial class MainWindowViewModel : ViewModelBase
public TaskDetailViewModel TaskDetail { get; } public TaskDetailViewModel TaskDetail { get; }
public StatusBarViewModel StatusBar { get; } public StatusBarViewModel StatusBar { get; }
private readonly Action<string> _onTaskChanged;
public MainWindowViewModel( public MainWindowViewModel(
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorkerClient worker, WorkerClient worker,
@@ -42,8 +44,15 @@ public partial class MainWindowViewModel : ViewModelBase
TaskDetail = taskDetail; TaskDetail = taskDetail;
StatusBar = statusBar; StatusBar = statusBar;
_onTaskChanged = taskId => _ = TaskList.RefreshSingleAsync(taskId);
TaskList.SelectedTaskChanged += OnSelectedTaskChanged; 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() public async Task InitializeAsync()
@@ -61,7 +70,11 @@ public partial class MainWindowViewModel : ViewModelBase
StatusBar.ShowMessage($"Error loading lists: {ex.Message}"); 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) partial void OnSelectedListChanged(ListItemViewModel? value)
@@ -154,23 +167,46 @@ public partial class MainWindowViewModel : ViewModelBase
} }
} }
[ObservableProperty] private bool _isDeleteConfirmVisible;
private ListItemViewModel? _pendingDeleteList;
[RelayCommand] [RelayCommand]
private async Task DeleteList() private void DeleteList()
{ {
if (SelectedList is null) return; 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 try
{ {
using var context = _dbFactory.CreateDbContext(); using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context); var listRepo = new ListRepository(context);
await listRepo.DeleteAsync(SelectedList.Id); await listRepo.DeleteAsync(_pendingDeleteList.Id);
Lists.Remove(SelectedList); Lists.Remove(_pendingDeleteList);
SelectedList = null; if (SelectedList == _pendingDeleteList)
SelectedList = null;
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusBar.ShowMessage($"Error deleting list: {ex.Message}"); 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) private static async Task ShowDialogAsync(Window dialog)

View File

@@ -85,6 +85,12 @@ public partial class TaskDetailViewModel : ViewModelBase
var ct = cts.Token; var ct = cts.Token;
_taskId = taskId; _taskId = taskId;
HasWorktree = false;
WorktreeState = "";
BranchName = null;
DiffStat = null;
WorktreePath = null;
OnPropertyChanged(nameof(CanWorktreeAction));
LiveText = ""; LiveText = "";
_formatter = new StreamLineFormatter(); _formatter = new StreamLineFormatter();
@@ -282,16 +288,18 @@ public partial class TaskDetailViewModel : ViewModelBase
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return; if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
try try
{ {
if (_taskId is null) return;
using var context = _dbFactory.CreateDbContext(); using var context = _dbFactory.CreateDbContext();
var orig = await context.Subtasks.AsNoTracking().FirstOrDefaultAsync(s => s.Id == vm.Id);
var subtaskRepo = new SubtaskRepository(context); var subtaskRepo = new SubtaskRepository(context);
await subtaskRepo.UpdateAsync(new SubtaskEntity await subtaskRepo.UpdateAsync(new SubtaskEntity
{ {
Id = vm.Id, Id = vm.Id,
TaskId = _taskId ?? "", TaskId = _taskId,
Title = vm.Title, Title = vm.Title,
Completed = vm.Completed, Completed = vm.Completed,
OrderNum = Subtasks.IndexOf(vm), OrderNum = Subtasks.IndexOf(vm),
CreatedAt = DateTime.UtcNow, CreatedAt = orig?.CreatedAt ?? DateTime.UtcNow,
}); });
} }
catch (Exception ex) catch (Exception ex)
@@ -378,13 +386,15 @@ public partial class TaskDetailViewModel : ViewModelBase
UseShellExecute = true, UseShellExecute = true,
}); });
} }
catch { /* best effort */ } catch (Exception ex)
{
Debug.WriteLine($"Failed to open worktree: {ex.Message}");
}
} }
[RelayCommand] [RelayCommand]
private void ShowDiff() private void ShowDiff()
{ {
// TODO: open a proper diff viewer; for now open git diff in a console
if (WorktreePath is null) return; if (WorktreePath is null) return;
try try
{ {
@@ -395,7 +405,10 @@ public partial class TaskDetailViewModel : ViewModelBase
UseShellExecute = true, UseShellExecute = true,
}); });
} }
catch { /* best effort */ } catch (Exception ex)
{
Debug.WriteLine($"Failed to show diff: {ex.Message}");
}
} }
[RelayCommand] [RelayCommand]

View File

@@ -71,6 +71,7 @@ public partial class TaskEditorViewModel : ViewModelBase
public void InitForCreate(string listId, string defaultCommitType = "chore") public void InitForCreate(string listId, string defaultCommitType = "chore")
{ {
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = null; _editId = null;
_listId = listId; _listId = listId;
_createdAt = DateTime.UtcNow; _createdAt = DateTime.UtcNow;
@@ -81,6 +82,7 @@ public partial class TaskEditorViewModel : ViewModelBase
public async Task InitForEditAsync(TaskEntity entity, IReadOnlyList<TagEntity> taskTags, CancellationToken ct = default) public async Task InitForEditAsync(TaskEntity entity, IReadOnlyList<TagEntity> taskTags, CancellationToken ct = default)
{ {
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = entity.Id; _editId = entity.Id;
_listId = entity.ListId; _listId = entity.ListId;
_createdAt = entity.CreatedAt; _createdAt = entity.CreatedAt;
@@ -128,6 +130,7 @@ public partial class TaskEditorViewModel : ViewModelBase
// Keep old sync overload for callers that haven't loaded agents yet // Keep old sync overload for callers that haven't loaded agents yet
public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags) public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags)
{ {
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = entity.Id; _editId = entity.Id;
_listId = entity.ListId; _listId = entity.ListId;
_createdAt = entity.CreatedAt; _createdAt = entity.CreatedAt;
@@ -215,7 +218,10 @@ public partial class TaskEditorViewModel : ViewModelBase
{ {
if (vm.Id == "") continue; if (vm.Id == "") continue;
if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted) if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted)
await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow }); {
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 else
{ {
// update order_num if position changed // update order_num if position changed
@@ -254,9 +260,5 @@ public partial class TaskEditorViewModel : ViewModelBase
_tcs.TrySetResult(null); _tcs.TrySetResult(null);
} }
public Task<TaskEntity?> ShowAndWaitAsync() public Task<TaskEntity?> ShowAndWaitAsync() => _tcs.Task;
{
_tcs = new TaskCompletionSource<TaskEntity?>();
return _tcs.Task;
}
} }

View File

@@ -157,17 +157,20 @@ public partial class TaskListViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanAddTask))] [RelayCommand(CanExecute = nameof(CanAddTask))]
private async Task AddTask() private async Task AddTask()
{ {
var listId = CurrentListId;
if (listId is null) return;
string defaultCommitType; string defaultCommitType;
using (var context = _dbFactory.CreateDbContext()) using (var context = _dbFactory.CreateDbContext())
{ {
var listRepo = new ListRepository(context); var listRepo = new ListRepository(context);
var list = await listRepo.GetByIdAsync(CurrentListId); var list = await listRepo.GetByIdAsync(listId);
defaultCommitType = list?.DefaultCommitType ?? "chore"; defaultCommitType = list?.DefaultCommitType ?? "chore";
} }
var editor = _editorFactory(); var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker); await editor.LoadAgentsAsync(_worker);
editor.InitForCreate(CurrentListId, defaultCommitType); editor.InitForCreate(listId, defaultCommitType);
var window = new TaskEditorView { DataContext = editor }; var window = new TaskEditorView { DataContext = editor };
editor.RequestClose += () => window.Close(); editor.RequestClose += () => window.Close();
@@ -328,7 +331,14 @@ public partial class TaskListViewModel : ViewModelBase
private async void OnTaskUpdated(string taskId) private async void OnTaskUpdated(string taskId)
{ {
if (CurrentListId is null) return; if (CurrentListId is null) return;
await RefreshSingleAsync(taskId); try
{
await RefreshSingleAsync(taskId);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[TaskListViewModel] OnTaskUpdated failed for {taskId}: {ex}");
}
} }
private static async Task ShowDialogAsync(Window dialog) private static async Task ShowDialogAsync(Window dialog)

View File

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

View File

@@ -55,9 +55,15 @@ public sealed class ClaudeArgsBuilder
private static string Escape(string value) 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 $"\"{escaped}\"";
} }
return value; return value;

View File

@@ -125,7 +125,6 @@ public sealed class TaskRunner
var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId }; 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."; 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); var retryResult = await RunOnceAsync(task.Id, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
if (retryResult.IsSuccess) if (retryResult.IsSuccess)
@@ -216,7 +215,6 @@ public sealed class TaskRunner
await _broadcaster.TaskStarted(slot, taskId, now); await _broadcaster.TaskStarted(slot, taskId, now);
var nextRunNumber = lastRun.RunNumber + 1; var nextRunNumber = lastRun.RunNumber + 1;
await _broadcaster.RunCreated(taskId, nextRunNumber, false);
var result = await RunOnceAsync(taskId, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct); var result = await RunOnceAsync(taskId, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
if (result.IsSuccess) if (result.IsSuccess)
@@ -255,6 +253,8 @@ public sealed class TaskRunner
await runRepo.AddAsync(run, ct); await runRepo.AddAsync(run, ct);
} }
await _broadcaster.RunCreated(taskId, runNumber, isRetry);
var arguments = _argsBuilder.Build(config); var arguments = _argsBuilder.Build(config);
await using var logWriter = new LogWriter(logPath); await using var logWriter = new LogWriter(logPath);

View File

@@ -58,22 +58,28 @@ public sealed class QueueService : BackgroundService
public async Task RunNow(string taskId) public async Task RunNow(string taskId)
{ {
using var context = _dbFactory.CreateDbContext(); using (var context = _dbFactory.CreateDbContext())
var taskRepo = new TaskRepository(context); {
var task = await taskRepo.GetByIdAsync(taskId); var taskRepo = new TaskRepository(context);
if (task is null) var exists = await taskRepo.GetByIdAsync(taskId);
throw new KeyNotFoundException($"Task '{taskId}' not found."); if (exists is null)
throw new KeyNotFoundException($"Task '{taskId}' not found.");
}
lock (_lock) lock (_lock)
{ {
if (_queueSlot?.TaskId == taskId)
throw new InvalidOperationException("task is already running in queue slot");
if (_overrideSlot is not null) if (_overrideSlot is not null)
throw new InvalidOperationException("override slot busy"); throw new InvalidOperationException("override slot busy");
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
_overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts }; _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; } lock (_lock) { _overrideSlot = null; }
cts.Dispose(); cts.Dispose();
}, TaskScheduler.Default); }, TaskScheduler.Default);
@@ -88,18 +94,22 @@ public sealed class QueueService : BackgroundService
?? throw new KeyNotFoundException($"Task '{taskId}' not found."); ?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
if (task.Status == Data.Models.TaskStatus.Running) if (task.Status == Data.Models.TaskStatus.Running)
throw new InvalidOperationException("Task is currently running."); throw new InvalidOperationException("task is already running");
lock (_lock) lock (_lock)
{ {
if (_queueSlot?.TaskId == taskId)
throw new InvalidOperationException("task is already running in queue slot");
if (_overrideSlot is not null) if (_overrideSlot is not null)
throw new InvalidOperationException("override slot busy"); throw new InvalidOperationException("override slot busy");
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
_overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts }; _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; } lock (_lock) { _overrideSlot = null; }
cts.Dispose(); cts.Dispose();
}, TaskScheduler.Default); }, TaskScheduler.Default);
@@ -165,8 +175,10 @@ public sealed class QueueService : BackgroundService
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
_queueSlot = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts }; _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; } lock (_lock) { _queueSlot = null; }
cts.Dispose(); cts.Dispose();
WakeQueue(); // Check for next task immediately. WakeQueue(); // Check for next task immediately.
@@ -186,16 +198,25 @@ public sealed class QueueService : BackgroundService
_logger.LogInformation("QueueService stopping"); _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 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); await _runner.RunAsync(task, slot, ct);
} }
catch (Exception ex) 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

@@ -68,4 +68,28 @@ public sealed class ClaudeArgsBuilderTests
var args = _builder.Build(new ClaudeRunConfig(null, """Don't say "hello".""", null, null)); var args = _builder.Build(new ClaudeRunConfig(null, """Don't say "hello".""", null, null));
Assert.Contains("--append-system-prompt", args); 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

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