18 Commits

Author SHA1 Message Date
mika kuns
eff1045e63 General Changes 2026-04-20 09:54:13 +02:00
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
25 changed files with 590 additions and 116 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>
public static void MigrateAndConfigure(ClaudeDoDbContext db)
{
// If the 'lists' table exists but __EFMigrationsHistory does not,
// this is a pre-EF database. Baseline the InitialCreate migration.
var conn = db.Database.GetDbConnection();
conn.Open();
using (var cmd = conn.CreateCommand())
try
{
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'";
var hasLists = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
conn.Open();
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'";
var hasHistory = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
if (hasLists && !hasHistory)
// Set WAL FIRST, before migrations — prevents write-lock contention
// when UI and Worker start simultaneously.
using (var walCmd = conn.CreateCommand())
{
// Create the history table and mark InitialCreate as applied.
cmd.CommandText = """
CREATE TABLE "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260416064948_InitialCreate', '8.0.11');
""";
cmd.ExecuteNonQuery();
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();
}
}
}
conn.Close();
finally
{
conn.Close();
}
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)
{
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);
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)
{
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);
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)
{
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);
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)
{
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);
if (tag is not null)
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
public partial class MainWindowViewModel : ViewModelBase, IDisposable
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker;
@@ -27,6 +27,9 @@ public partial class MainWindowViewModel : ViewModelBase
public TaskDetailViewModel TaskDetail { get; }
public StatusBarViewModel StatusBar { get; }
private readonly Action<string> _onTaskChanged;
private readonly Action<string> _onTaskSubtasksChanged;
public MainWindowViewModel(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorkerClient worker,
@@ -42,8 +45,22 @@ public partial class MainWindowViewModel : ViewModelBase
TaskDetail = taskDetail;
StatusBar = statusBar;
_onTaskChanged = taskId => _ = TaskList.RefreshSingleAsync(taskId);
_onTaskSubtasksChanged = taskId =>
{
if (TaskDetail.CurrentTaskId == taskId)
_ = TaskDetail.RefreshSubtasksFromDbAsync();
};
TaskList.SelectedTaskChanged += OnSelectedTaskChanged;
TaskDetail.TaskChanged += taskId => _ = TaskList.RefreshSingleAsync(taskId);
TaskList.TaskSubtasksChanged += _onTaskSubtasksChanged;
TaskDetail.TaskChanged += _onTaskChanged;
}
public void Dispose()
{
TaskList.SelectedTaskChanged -= OnSelectedTaskChanged;
TaskList.TaskSubtasksChanged -= _onTaskSubtasksChanged;
TaskDetail.TaskChanged -= _onTaskChanged;
}
public async Task InitializeAsync()
@@ -61,7 +78,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)
@@ -154,23 +175,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
{
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
await listRepo.DeleteAsync(SelectedList.Id);
Lists.Remove(SelectedList);
SelectedList = null;
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

@@ -51,6 +51,7 @@ public partial class TaskDetailViewModel : ViewModelBase
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
private string? _taskId;
public string? CurrentTaskId => _taskId;
private string? _listId;
private bool _isLoading;
// Cancels an in-flight LoadAsync when a new TaskUpdated event arrives
@@ -85,6 +86,12 @@ public partial class TaskDetailViewModel : ViewModelBase
var ct = cts.Token;
_taskId = taskId;
HasWorktree = false;
WorktreeState = "";
BranchName = null;
DiffStat = null;
WorktreePath = null;
OnPropertyChanged(nameof(CanWorktreeAction));
LiveText = "";
_formatter = new StreamLineFormatter();
@@ -261,11 +268,13 @@ public partial class TaskDetailViewModel : ViewModelBase
var vm = SubtaskItemViewModel.From(entity);
vm.PropertyChanged += OnSubtaskPropertyChanged;
Subtasks.Add(vm);
TaskChanged?.Invoke(_taskId);
}
[RelayCommand]
private async Task RemoveSubtask(SubtaskItemViewModel item)
{
if (_taskId is null) return;
if (!string.IsNullOrEmpty(item.Id))
{
using var context = _dbFactory.CreateDbContext();
@@ -274,6 +283,7 @@ public partial class TaskDetailViewModel : ViewModelBase
}
item.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Remove(item);
TaskChanged?.Invoke(_taskId);
}
private async void OnSubtaskPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -282,17 +292,21 @@ public partial class TaskDetailViewModel : ViewModelBase
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
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,
});
if (e.PropertyName == nameof(SubtaskItemViewModel.Completed))
TaskChanged?.Invoke(_taskId);
}
catch (Exception ex)
{
@@ -301,6 +315,35 @@ public partial class TaskDetailViewModel : ViewModelBase
}
}
public async Task RefreshSubtasksFromDbAsync()
{
if (_taskId is null) return;
List<SubtaskEntity> subtasks;
using (var context = _dbFactory.CreateDbContext())
{
var subtaskRepo = new SubtaskRepository(context);
subtasks = await subtaskRepo.GetByTaskIdAsync(_taskId);
}
_isLoading = true;
try
{
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Clear();
foreach (var s in subtasks)
{
var vm = SubtaskItemViewModel.From(s);
vm.PropertyChanged += OnSubtaskPropertyChanged;
Subtasks.Add(vm);
}
}
finally
{
_isLoading = false;
}
}
public void SetAgentFromPath(string path)
{
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
@@ -378,13 +421,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
{
@@ -395,7 +440,10 @@ public partial class TaskDetailViewModel : ViewModelBase
UseShellExecute = true,
});
}
catch { /* best effort */ }
catch (Exception ex)
{
Debug.WriteLine($"Failed to show diff: {ex.Message}");
}
}
[RelayCommand]

View File

@@ -71,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;
@@ -81,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;
@@ -128,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;
@@ -215,7 +218,10 @@ public partial class TaskEditorViewModel : ViewModelBase
{
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
@@ -254,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

@@ -32,13 +32,15 @@ public partial class TaskItemViewModel : ViewModelBase
private readonly Func<string, Task>? _runNow;
private readonly Func<bool> _canRunNow;
private readonly Func<string, Task>? _toggleDone;
private readonly Action<string>? _onSubtasksChanged;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private bool _subtasksLoaded;
public TaskItemViewModel(TaskEntity entity, IReadOnlyList<TagEntity> tags,
Func<string, Task>? runNow, Func<bool> canRunNow,
IDbContextFactory<ClaudeDoDbContext> dbFactory, int subtaskCount,
Func<string, Task>? toggleDone = null)
Func<string, Task>? toggleDone = null,
Action<string>? onSubtasksChanged = null)
{
Entity = entity;
Id = entity.Id;
@@ -52,6 +54,7 @@ public partial class TaskItemViewModel : ViewModelBase
_runNow = runNow;
_canRunNow = canRunNow;
_toggleDone = toggleDone;
_onSubtasksChanged = onSubtasksChanged;
_dbFactory = dbFactory;
_subtaskCount = subtaskCount;
_hasSubtasks = subtaskCount > 0;
@@ -154,6 +157,8 @@ public partial class TaskItemViewModel : ViewModelBase
entity.Completed = vm.Completed;
await context.SaveChangesAsync();
}
_onSubtasksChanged?.Invoke(Id);
}
public async Task RefreshSubtasksAsync(int newCount)

View File

@@ -29,10 +29,14 @@ public partial class TaskListViewModel : ViewModelBase
[ObservableProperty] private string _inlineAddTitle = "";
public event Action<TaskItemViewModel?>? SelectedTaskChanged;
public event Action<string>? TaskSubtasksChanged;
partial void OnSelectedTaskChanged(TaskItemViewModel? value) =>
SelectedTaskChanged?.Invoke(value);
private void NotifySubtasksChanged(string taskId) =>
TaskSubtasksChanged?.Invoke(taskId);
public TaskListViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker,
Func<TaskEditorViewModel> editorFactory, Action<string> showMessage)
{
@@ -101,7 +105,7 @@ public partial class TaskListViewModel : ViewModelBase
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));
_dbFactory, count, ToggleDoneAsync, NotifySubtasksChanged));
}
}
catch (Exception ex)
@@ -143,7 +147,7 @@ public partial class TaskListViewModel : ViewModelBase
await taskRepo.AddAsync(entity);
var tags = await taskRepo.GetEffectiveTagsAsync(entity.Id);
var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, 0, ToggleDoneAsync);
_dbFactory, 0, ToggleDoneAsync, NotifySubtasksChanged);
Tasks.Add(vm);
SelectedTask = vm;
InlineAddTitle = "";
@@ -157,17 +161,20 @@ public partial class TaskListViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanAddTask))]
private async Task AddTask()
{
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(CurrentListId);
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();
@@ -192,7 +199,7 @@ public partial class TaskListViewModel : ViewModelBase
var tags = await taskRepo.GetEffectiveTagsAsync(saved.Id);
Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, 0, ToggleDoneAsync));
_dbFactory, 0, ToggleDoneAsync, NotifySubtasksChanged));
// Auto wake-queue if agent+queued
if (saved.Status == TaskStatus.Queued &&
@@ -328,7 +335,14 @@ public partial class TaskListViewModel : ViewModelBase
private async void OnTaskUpdated(string taskId)
{
if (CurrentListId is null) return;
await RefreshSingleAsync(taskId);
try
{
await RefreshSingleAsync(taskId);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[TaskListViewModel] OnTaskUpdated failed for {taskId}: {ex}");
}
}
private static async Task ShowDialogAsync(Window dialog)

View File

@@ -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

@@ -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

@@ -125,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)
@@ -216,7 +215,6 @@ public sealed class TaskRunner
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)
@@ -255,6 +253,8 @@ public sealed class TaskRunner
await runRepo.AddAsync(run, ct);
}
await _broadcaster.RunCreated(taskId, runNumber, isRetry);
var arguments = _argsBuilder.Build(config);
await using var logWriter = new LogWriter(logPath);

View File

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

@@ -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

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