Compare commits
17 Commits
feat/subta
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a8cd97d02 | ||
|
|
09e8b1f10b | ||
|
|
92d8d902df | ||
|
|
aa1008dcff | ||
|
|
5f3d41e1f6 | ||
|
|
7d48f34b15 | ||
|
|
51a1bbe6b8 | ||
|
|
ad7c9facaf | ||
|
|
11a4376da5 | ||
|
|
f10ad69863 | ||
|
|
dc4571a338 | ||
|
|
4fb6ba6be8 | ||
|
|
3423919655 | ||
|
|
fca2bdb596 | ||
|
|
721f0cd903 | ||
|
|
4f25c3dd40 | ||
| 33fedc7e26 |
118
docs/superpowers/specs/2026-04-16-subtask-tree-view-design.md
Normal file
118
docs/superpowers/specs/2026-04-16-subtask-tree-view-design.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Subtask Tree View in Task List
|
||||
|
||||
## Problem
|
||||
|
||||
Subtasks are invisible in the task list — users only see them after opening the detail pane or editor modal. This makes it hard to get an overview of task progress without clicking into each task individually.
|
||||
|
||||
## Solution
|
||||
|
||||
Show subtasks indented below their parent task in the task list, with expand/collapse. Tasks start collapsed with a visual indicator when subtasks exist.
|
||||
|
||||
## Scope
|
||||
|
||||
Pure UI/ViewModel change. No data model changes, no new migrations, no repository schema changes.
|
||||
|
||||
## Design
|
||||
|
||||
### ViewModel Changes
|
||||
|
||||
**TaskItemViewModel** — add:
|
||||
|
||||
- `ObservableCollection<SubtaskItemViewModel> Subtasks` — populated on first expand
|
||||
- `bool IsExpanded` — observable, default `false`; toggles subtask visibility
|
||||
- `bool HasSubtasks` — observable, set during initial load from a count query
|
||||
- `int SubtaskCount` — observable, used for the indicator
|
||||
- `ToggleExpandedCommand` — flips `IsExpanded`; on first expand, loads subtasks from `SubtaskRepository.GetByTaskIdAsync`
|
||||
- `ToggleSubtaskDoneCommand(string subtaskId)` — toggles a subtask's `Completed` and persists via `SubtaskRepository.UpdateAsync`
|
||||
|
||||
Constructor gains `SubtaskRepository` and initial `subtaskCount` parameter.
|
||||
|
||||
**TaskListViewModel.LoadAsync** — after fetching tasks, run a single batch query to get subtask counts per task. Pass counts into each `TaskItemViewModel`. This avoids N+1 queries on load.
|
||||
|
||||
**TaskListViewModel.RefreshSingleAsync** — if the refreshed task's `IsExpanded` is true, also reload its subtasks from DB and update the collection.
|
||||
|
||||
### Repository Changes
|
||||
|
||||
**SubtaskRepository** — add one method:
|
||||
|
||||
```csharp
|
||||
Task<Dictionary<string, int>> GetCountsByTaskIdsAsync(IEnumerable<string> taskIds, CancellationToken ct = default)
|
||||
```
|
||||
|
||||
Single query: `SELECT task_id, COUNT(*) FROM subtasks WHERE task_id IN (...) GROUP BY task_id`. Returns a map of taskId -> count. Tasks with no subtasks won't appear in the result (count defaults to 0).
|
||||
|
||||
### XAML Changes
|
||||
|
||||
**TaskListView.axaml** — the `DataTemplate` for `TaskItemViewModel` becomes a 2-row grid:
|
||||
|
||||
```
|
||||
Row 0: [ExpandChevron] [StatusCircle] [Title + Tags/Status subtitle]
|
||||
Row 1: [SubtaskItemsControl, margin-left ~40px, visible when IsExpanded]
|
||||
```
|
||||
|
||||
**Row 0 — Expand chevron:**
|
||||
- Column 0 gets a small chevron button (12x12 `Path` data) before the status circle
|
||||
- Right-pointing when collapsed, down-pointing when expanded
|
||||
- Bound to `ToggleExpandedCommand`
|
||||
- Only visible when `HasSubtasks` is true (via `IsVisible` binding)
|
||||
- When `HasSubtasks` is false, the space is empty but reserved (fixed-width column) so all titles align
|
||||
|
||||
**Row 1 — Subtask list:**
|
||||
- `ItemsControl` bound to `Subtasks`
|
||||
- `IsVisible` bound to `IsExpanded`
|
||||
- Left margin ~40px for visual indentation
|
||||
- Each subtask item: `CheckBox` (bound to `Completed`) + `TextBlock` (bound to `Title`)
|
||||
- Subtask row has its own context menu flyout with "Edit Task" (opens parent task's editor modal via `EditTaskCommand` on root `TaskListViewModel`)
|
||||
- Checkbox toggle calls `ToggleSubtaskDoneCommand` on the parent `TaskItemViewModel`
|
||||
|
||||
**Column layout change:** The existing 2-column `Grid` (`Auto, *`) gets a third column prepended: `Auto, Auto, *`. The chevron goes in column 0, status circle in column 1, title stack in column 2. Row 1 spans all 3 columns.
|
||||
|
||||
### Subtask Checkbox Interaction
|
||||
|
||||
When a subtask checkbox is toggled in the list:
|
||||
1. Update the `SubtaskItemViewModel.Completed` property
|
||||
2. Call `SubtaskRepository.UpdateAsync` with the updated entity (same auto-save pattern as `TaskDetailView`)
|
||||
3. No need to refresh the parent task — subtask completion doesn't affect task status
|
||||
|
||||
### Subtask Context Menu
|
||||
|
||||
Right-click on a subtask row shows:
|
||||
- "Edit Task" — opens the parent task's editor modal (same flow as `EditTaskCommand`)
|
||||
|
||||
This reuses the existing editor which already has full subtask editing (add/remove/reorder/rename).
|
||||
|
||||
### Real-time Updates
|
||||
|
||||
When `RefreshSingleAsync` fires (via SignalR `TaskUpdatedEvent`):
|
||||
1. Reload subtask count, update `HasSubtasks` and `SubtaskCount`
|
||||
2. If `IsExpanded`, reload subtask list from DB and reconcile with the observable collection
|
||||
|
||||
### Detail Pane Sync
|
||||
|
||||
When the user edits subtasks in `TaskDetailView` (auto-save) or `TaskEditorView` (batch-save), the list view's subtask state may become stale. Two options:
|
||||
|
||||
**Chosen approach:** The detail pane and editor already trigger `TaskUpdatedEvent` (or the editor's save path calls `RefreshSingleAsync` via `SelectedTask.Refresh`). Extend `Refresh` on `TaskItemViewModel` to also reload subtasks if expanded, and update `HasSubtasks`/`SubtaskCount`.
|
||||
|
||||
### Visual Style
|
||||
|
||||
- Chevron: 10x10 path, `TextDimBrush` color, no background, cursor=Hand
|
||||
- Subtask rows: smaller font (12px), `TextDimBrush` for unchecked title, strikethrough + dimmed for completed
|
||||
- Subtask checkbox: standard Avalonia `CheckBox` (no custom circular border), small size
|
||||
- Subtask row vertical padding: 2px (compact)
|
||||
- Indent: 40px left margin on the subtask `ItemsControl`
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `src/ClaudeDo.Data/Repositories/SubtaskRepository.cs` — add `GetCountsByTaskIdsAsync`
|
||||
2. `src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs` — add subtask collection, expand/collapse, toggle done
|
||||
3. `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — batch-load counts, pass SubtaskRepository, extend refresh
|
||||
4. `src/ClaudeDo.Ui/Views/TaskListView.axaml` — restructure item template with chevron + nested ItemsControl
|
||||
5. `src/ClaudeDo.Ui/Views/TaskListView.axaml.cs` — handle subtask context menu pointer-pressed if needed
|
||||
6. `src/ClaudeDo.App/Program.cs` — pass SubtaskRepository to TaskListViewModel (if not already available via DI)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Drag-to-reorder subtasks in the list view
|
||||
- Add subtask directly from the list view
|
||||
- Subtask progress indicator (e.g., "2/5 done") on collapsed tasks
|
||||
- Recursive task nesting (tasks containing tasks)
|
||||
@@ -30,10 +30,21 @@ public class ClaudeDoDbContext : DbContext
|
||||
/// </summary>
|
||||
public static void MigrateAndConfigure(ClaudeDoDbContext db)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
try
|
||||
{
|
||||
conn.Open();
|
||||
|
||||
// Set WAL FIRST, before migrations — prevents write-lock contention
|
||||
// when UI and Worker start simultaneously.
|
||||
using (var walCmd = conn.CreateCommand())
|
||||
{
|
||||
walCmd.CommandText = "PRAGMA journal_mode=wal;";
|
||||
walCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// If the 'lists' table exists but __EFMigrationsHistory does not,
|
||||
// this is a pre-EF database. Baseline the InitialCreate migration.
|
||||
var conn = db.Database.GetDbConnection();
|
||||
conn.Open();
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'";
|
||||
@@ -44,7 +55,6 @@ public class ClaudeDoDbContext : DbContext
|
||||
|
||||
if (hasLists && !hasHistory)
|
||||
{
|
||||
// Create the history table and mark InitialCreate as applied.
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE "__EFMigrationsHistory" (
|
||||
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
|
||||
@@ -56,9 +66,12 @@ public class ClaudeDoDbContext : DbContext
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
conn.Close();
|
||||
}
|
||||
|
||||
db.Database.Migrate();
|
||||
db.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -110,14 +110,16 @@ public partial class App : Application
|
||||
sc.AddSingleton<IInstallStep, WriteConfigStep>();
|
||||
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
||||
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartServiceStep>());
|
||||
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
||||
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
|
||||
sc.AddSingleton<WriteInstallManifestStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
|
||||
|
||||
// Stop/Start — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
|
||||
// Stop — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
|
||||
// Pulled by Update flow + Repair/Uninstall.
|
||||
sc.AddSingleton<StopServiceStep>();
|
||||
// StartServiceStep is also registered as IInstallStep above (fresh-install pipeline).
|
||||
sc.AddSingleton<StartServiceStep>();
|
||||
|
||||
// Runners
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed class InstallContext
|
||||
public int SignalRPort { get; set; } = 47_821;
|
||||
public int QueueBackstopIntervalMs { get; set; } = 30_000;
|
||||
public string ClaudeBin { get; set; } = "claude";
|
||||
public string ServiceAccount { get; set; } = "LocalSystem";
|
||||
public string ServiceAccount { get; set; } = "CurrentUser";
|
||||
public bool AutoStart { get; set; } = true;
|
||||
public int RestartDelayMs { get; set; } = 5000;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed class UninstallRunner
|
||||
_stopService = stopService;
|
||||
}
|
||||
|
||||
public async Task<StepResult> RunAsync(IProgress<string> progress, CancellationToken ct)
|
||||
public async Task<StepResult> RunAsync(bool removeAppData, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
// 1) Validate install dir up front — refuse obviously unsafe paths.
|
||||
// Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\.
|
||||
@@ -67,7 +67,9 @@ public sealed class UninstallRunner
|
||||
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
|
||||
}
|
||||
|
||||
// 6) Delete ~/.todo-app (config + DB + logs) — user opted into full removal.
|
||||
// 6) Delete ~/.todo-app (config + DB + logs) — only if user opted in.
|
||||
if (removeAppData)
|
||||
{
|
||||
var appData = Paths.AppDataRoot();
|
||||
if (Directory.Exists(appData))
|
||||
{
|
||||
@@ -75,6 +77,7 @@ public sealed class UninstallRunner
|
||||
if (!TryDeleteDir(appData, out var err))
|
||||
failures.Add($"app data ({appData}): {err}");
|
||||
}
|
||||
}
|
||||
|
||||
// 7) If we were launched from inside the install dir (Apps & Features case),
|
||||
// our own exe is still locked — schedule a cmd.exe trampoline to finish
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -46,10 +46,9 @@ public sealed class RegisterServiceStep : IInstallStep
|
||||
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
|
||||
|
||||
if (ctx.ServiceAccount == "CurrentUser")
|
||||
{
|
||||
var username = Environment.UserName;
|
||||
createArgs += $" obj= \".\\{username}\"";
|
||||
}
|
||||
return StepResult.Fail(
|
||||
"Service cannot run as Current User without a password. " +
|
||||
"Select 'Local System' or extend ServicePage to capture a password.");
|
||||
|
||||
progress.Report("Creating service...");
|
||||
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
|
||||
@@ -68,15 +67,6 @@ public sealed class RegisterServiceStep : IInstallStep
|
||||
if (failExit != 0)
|
||||
progress.Report($"Warning: failed to set restart policy (exit {failExit})");
|
||||
|
||||
// Start service if auto-start
|
||||
if (ctx.AutoStart)
|
||||
{
|
||||
progress.Report("Starting service...");
|
||||
var (startExit, _) = await RunSc($"start {ServiceName}", ctx, progress, ct);
|
||||
if (startExit != 0)
|
||||
progress.Report("Warning: service created but failed to start. You may need to start it manually.");
|
||||
}
|
||||
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
@@ -10,13 +11,15 @@ public sealed class WriteConfigStep : IInstallStep
|
||||
{
|
||||
try
|
||||
{
|
||||
// Expand ~ to the installing user's absolute path so the worker
|
||||
// service always finds the correct DB regardless of service account.
|
||||
var workerCfg = new InstallerWorkerConfig
|
||||
{
|
||||
DbPath = ctx.DbPath,
|
||||
SandboxRoot = ctx.SandboxRoot,
|
||||
LogRoot = ctx.LogRoot,
|
||||
DbPath = Paths.Expand(ctx.DbPath),
|
||||
SandboxRoot = Paths.Expand(ctx.SandboxRoot),
|
||||
LogRoot = Paths.Expand(ctx.LogRoot),
|
||||
WorktreeRootStrategy = ctx.WorktreeRootStrategy,
|
||||
CentralWorktreeRoot = ctx.CentralWorktreeRoot,
|
||||
CentralWorktreeRoot = Paths.Expand(ctx.CentralWorktreeRoot),
|
||||
QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs,
|
||||
SignalRPort = ctx.SignalRPort,
|
||||
ClaudeBin = ctx.ClaudeBin,
|
||||
@@ -26,7 +29,7 @@ public sealed class WriteConfigStep : IInstallStep
|
||||
|
||||
var uiCfg = new InstallerAppSettings
|
||||
{
|
||||
DbPath = ctx.UiDbPath,
|
||||
DbPath = Paths.Expand(ctx.UiDbPath),
|
||||
SignalRUrl = ctx.SignalRUrl,
|
||||
};
|
||||
uiCfg.Save();
|
||||
|
||||
@@ -29,6 +29,9 @@ public partial class SettingsViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private string _versionLabel = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _removeAppData;
|
||||
|
||||
public SettingsViewModel(
|
||||
PageResolver resolver,
|
||||
InstallContext context,
|
||||
@@ -133,8 +136,12 @@ public partial class SettingsViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private async Task Uninstall()
|
||||
{
|
||||
var dataNote = RemoveAppData
|
||||
? "This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?"
|
||||
: "This will remove ClaudeDo. Your tasks, configuration, and database in ~/.todo-app will be kept.\n\nContinue?";
|
||||
|
||||
var confirm = MessageBox.Show(
|
||||
"This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?",
|
||||
dataNote,
|
||||
"Uninstall ClaudeDo",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Warning);
|
||||
@@ -142,7 +149,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
if (confirm != MessageBoxResult.Yes) return;
|
||||
|
||||
var progress = new Progress<string>(msg => StatusMessage = msg);
|
||||
var r = await _uninstallRunner.RunAsync(progress, CancellationToken.None);
|
||||
var r = await _uninstallRunner.RunAsync(RemoveAppData, progress, CancellationToken.None);
|
||||
|
||||
if (!r.Success)
|
||||
{
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Status message / version label -->
|
||||
@@ -88,14 +89,17 @@
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1" Content="Uninstall" Margin="0,0,8,0"
|
||||
<CheckBox Grid.Column="1" IsChecked="{Binding RemoveAppData}"
|
||||
Content="Remove user data (tasks, logs, configs in ~/.todo-app)"
|
||||
Margin="0,0,12,0" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="2" Content="Uninstall" Margin="0,0,8,0"
|
||||
Command="{Binding UninstallCommand}"/>
|
||||
<Button Grid.Column="2" Content="Repair" Margin="0,0,8,0"
|
||||
<Button Grid.Column="3" Content="Repair" Margin="0,0,8,0"
|
||||
Command="{Binding RepairCommand}"/>
|
||||
<Button Grid.Column="3" Content="Save" Margin="0,0,8,0"
|
||||
<Button Grid.Column="4" Content="Save" Margin="0,0,8,0"
|
||||
Command="{Binding SaveCommand}"
|
||||
Style="{StaticResource AccentButton}"/>
|
||||
<Button Grid.Column="4" Content="Close"
|
||||
<Button Grid.Column="5" Content="Close"
|
||||
Command="{Binding CloseCommand}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
||||
using Avalonia.Threading;
|
||||
using ClaudeDo.Data.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
@@ -208,9 +209,13 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
||||
ActiveTasks.Add(new ActiveTask(a.Slot, a.TaskId, a.StartedAt));
|
||||
});
|
||||
}
|
||||
catch
|
||||
catch (HubException)
|
||||
{
|
||||
// Worker might not support GetActive yet
|
||||
// Expected: worker doesn't support GetActive yet
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"SeedActiveTasksAsync failed: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ public partial class ListEditorViewModel : ViewModelBase
|
||||
|
||||
public void InitForCreate()
|
||||
{
|
||||
_tcs = new TaskCompletionSource<ListEntity?>();
|
||||
_editId = null;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
WindowTitle = "New List";
|
||||
@@ -61,6 +62,7 @@ public partial class ListEditorViewModel : ViewModelBase
|
||||
|
||||
public void InitForEdit(ListEntity entity, ListConfigEntity? config)
|
||||
{
|
||||
_tcs = new TaskCompletionSource<ListEntity?>();
|
||||
_editId = entity.Id;
|
||||
_createdAt = entity.CreatedAt;
|
||||
Name = entity.Name;
|
||||
@@ -119,9 +121,5 @@ public partial class ListEditorViewModel : ViewModelBase
|
||||
_tcs.TrySetResult(null);
|
||||
}
|
||||
|
||||
public Task<ListEntity?> ShowAndWaitAsync()
|
||||
{
|
||||
_tcs = new TaskCompletionSource<ListEntity?>();
|
||||
return _tcs.Task;
|
||||
}
|
||||
public Task<ListEntity?> ShowAndWaitAsync() => _tcs.Task;
|
||||
}
|
||||
|
||||
@@ -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,8 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
public TaskDetailViewModel TaskDetail { get; }
|
||||
public StatusBarViewModel StatusBar { get; }
|
||||
|
||||
private readonly Action<string> _onTaskChanged;
|
||||
|
||||
public MainWindowViewModel(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
WorkerClient worker,
|
||||
@@ -42,8 +44,15 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
TaskDetail = taskDetail;
|
||||
StatusBar = statusBar;
|
||||
|
||||
_onTaskChanged = taskId => _ = TaskList.RefreshSingleAsync(taskId);
|
||||
TaskList.SelectedTaskChanged += OnSelectedTaskChanged;
|
||||
TaskDetail.TaskChanged += taskId => _ = TaskList.RefreshSingleAsync(taskId);
|
||||
TaskDetail.TaskChanged += _onTaskChanged;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
TaskList.SelectedTaskChanged -= OnSelectedTaskChanged;
|
||||
TaskDetail.TaskChanged -= _onTaskChanged;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
@@ -61,7 +70,11 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
StatusBar.ShowMessage($"Error loading lists: {ex.Message}");
|
||||
}
|
||||
|
||||
_ = _worker.StartAsync();
|
||||
_ = _worker.StartAsync().ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
System.Diagnostics.Debug.WriteLine($"Worker connection failed: {t.Exception?.Message}");
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
partial void OnSelectedListChanged(ListItemViewModel? value)
|
||||
@@ -154,23 +167,46 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty] private bool _isDeleteConfirmVisible;
|
||||
private ListItemViewModel? _pendingDeleteList;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteList()
|
||||
private void DeleteList()
|
||||
{
|
||||
if (SelectedList is null) return;
|
||||
// TODO: confirmation dialog
|
||||
_pendingDeleteList = SelectedList;
|
||||
IsDeleteConfirmVisible = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ConfirmDeleteList()
|
||||
{
|
||||
IsDeleteConfirmVisible = false;
|
||||
if (_pendingDeleteList is null) return;
|
||||
try
|
||||
{
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var listRepo = new ListRepository(context);
|
||||
await listRepo.DeleteAsync(SelectedList.Id);
|
||||
Lists.Remove(SelectedList);
|
||||
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)
|
||||
|
||||
@@ -85,6 +85,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();
|
||||
|
||||
@@ -282,16 +288,18 @@ 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,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -378,13 +386,15 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"Failed to open worktree: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowDiff()
|
||||
{
|
||||
// TODO: open a proper diff viewer; for now open git diff in a console
|
||||
if (WorktreePath is null) return;
|
||||
try
|
||||
{
|
||||
@@ -395,7 +405,10 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"Failed to show diff: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -157,17 +157,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();
|
||||
@@ -328,8 +331,15 @@ public partial class TaskListViewModel : ViewModelBase
|
||||
private async void OnTaskUpdated(string taskId)
|
||||
{
|
||||
if (CurrentListId is null) return;
|
||||
try
|
||||
{
|
||||
await RefreshSingleAsync(taskId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[TaskListViewModel] OnTaskUpdated failed for {taskId}: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ShowDialogAsync(Window dialog)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -58,22 +58,28 @@ public sealed class QueueService : BackgroundService
|
||||
|
||||
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);
|
||||
if (task is null)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user