refactor(tags): remove tag entity and all references

Drops TagEntity, TagRepository, and tag wiring across data layer, worker,
and UI. Adds RemoveTags migration to clean up schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-05-19 08:07:24 +02:00
parent 8d34db3f9b
commit 623ebf147b
42 changed files with 333 additions and 1118 deletions

View File

@@ -12,7 +12,6 @@ public class ClaudeDoDbContext : DbContext
public DbSet<TaskEntity> Tasks => Set<TaskEntity>(); public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>(); public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<TagEntity> Tags => Set<TagEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>(); public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>(); public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>(); public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();

View File

@@ -21,16 +21,5 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
.WithOne(c => c.List) .WithOne(c => c.List)
.HasForeignKey<ListConfigEntity>(c => c.ListId) .HasForeignKey<ListConfigEntity>(c => c.ListId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.HasMany(l => l.Tags)
.WithMany(tag => tag.Lists)
.UsingEntity("list_tags",
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
r => r.HasOne(typeof(ListEntity)).WithMany().HasForeignKey("list_id").OnDelete(DeleteBehavior.Cascade),
j =>
{
j.HasKey("list_id", "tag_id");
j.ToTable("list_tags");
});
} }
} }

View File

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

View File

@@ -112,17 +112,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
.WithOne(w => w.Task) .WithOne(w => w.Task)
.HasForeignKey<WorktreeEntity>(w => w.TaskId); .HasForeignKey<WorktreeEntity>(w => w.TaskId);
builder.HasMany(t => t.Tags)
.WithMany(tag => tag.Tasks)
.UsingEntity("task_tags",
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
r => r.HasOne(typeof(TaskEntity)).WithMany().HasForeignKey("task_id").OnDelete(DeleteBehavior.Cascade),
j =>
{
j.HasKey("task_id", "tag_id");
j.ToTable("task_tags");
});
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id"); builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status"); builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort"); builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");

View File

@@ -0,0 +1,115 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class RemoveTags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "list_tags");
migrationBuilder.DropTable(
name: "task_tags");
migrationBuilder.DropTable(
name: "tags");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "tags",
columns: table => new
{
id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
name = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_tags", x => x.id);
});
migrationBuilder.CreateTable(
name: "list_tags",
columns: table => new
{
list_id = table.Column<string>(type: "TEXT", nullable: false),
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_list_tags", x => new { x.list_id, x.tag_id });
table.ForeignKey(
name: "FK_list_tags_lists_list_id",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_list_tags_tags_tag_id",
column: x => x.tag_id,
principalTable: "tags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "task_tags",
columns: table => new
{
task_id = table.Column<string>(type: "TEXT", nullable: false),
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_task_tags", x => new { x.task_id, x.tag_id });
table.ForeignKey(
name: "FK_task_tags_tags_tag_id",
column: x => x.tag_id,
principalTable: "tags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_task_tags_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "tags",
columns: new[] { "id", "name" },
values: new object[,]
{
{ 1L, "agent" },
{ 2L, "manual" }
});
migrationBuilder.CreateIndex(
name: "IX_list_tags_tag_id",
table: "list_tags",
column: "tag_id");
migrationBuilder.CreateIndex(
name: "IX_tags_name",
table: "tags",
column: "name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_task_tags_tag_id",
table: "task_tags",
column: "tag_id");
}
}
}

View File

@@ -230,38 +230,6 @@ namespace ClaudeDo.Data.Migrations
b.ToTable("subtasks", (string)null); b.ToTable("subtasks", (string)null);
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -526,36 +494,6 @@ namespace ClaudeDo.Data.Migrations
b.ToTable("worktrees", (string)null); b.ToTable("worktrees", (string)null);
}); });
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{ {
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
@@ -623,36 +561,6 @@ namespace ClaudeDo.Data.Migrations
b.Navigation("Task"); b.Navigation("Task");
}); });
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{ {
b.Navigation("Config"); b.Navigation("Config");

View File

@@ -11,5 +11,4 @@ public sealed class ListEntity
// Navigation properties // Navigation properties
public ListConfigEntity? Config { get; set; } public ListConfigEntity? Config { get; set; }
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>(); public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
} }

View File

@@ -1,11 +0,0 @@
namespace ClaudeDo.Data.Models;
public sealed class TagEntity
{
public long Id { get; init; }
public required string Name { get; set; }
// Navigation properties
public ICollection<ListEntity> Lists { get; set; } = new List<ListEntity>();
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
}

View File

@@ -51,7 +51,6 @@ public sealed class TaskEntity
// Navigation properties // Navigation properties
public ListEntity List { get; set; } = null!; public ListEntity List { get; set; } = null!;
public WorktreeEntity? Worktree { get; set; } public WorktreeEntity? Worktree { get; set; }
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>(); public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>(); public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();

View File

@@ -36,38 +36,6 @@ public sealed class ListRepository
return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct); return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct);
} }
public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default)
{
return await _context.Lists
.Where(l => l.Id == listId)
.SelectMany(l => l.Tags)
.ToListAsync(ct);
}
public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
{
var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
if (list is null) return;
var tag = await _context.Tags.FindAsync([tagId], ct);
if (tag is not null && !list.Tags.Any(t => t.Id == tagId))
{
list.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
}
}
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
{
var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
if (list is null) return;
var tag = list.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null)
{
list.Tags.Remove(tag);
await _context.SaveChangesAsync(ct);
}
}
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default) public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
{ {
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct); return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);

View File

@@ -1,28 +0,0 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class TagRepository
{
private readonly ClaudeDoDbContext _context;
public TagRepository(ClaudeDoDbContext context) => _context = context;
public async Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)
{
return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct);
}
public async Task<long> GetOrCreateAsync(string name, CancellationToken ct = default)
{
var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
if (existing is not null)
return existing.Id;
var tag = new TagEntity { Name = name };
_context.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
return tag.Id;
}
}

View File

@@ -171,74 +171,6 @@ public sealed class TaskRepository
#endregion #endregion
#region Tags
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
{
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null) return;
var tag = await _context.Tags.FindAsync([tagId], ct);
if (tag is not null && !task.Tags.Any(t => t.Id == tagId))
{
task.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
}
}
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
{
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null) return;
var tag = task.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null)
{
task.Tags.Remove(tag);
await _context.SaveChangesAsync(ct);
}
}
public async Task SetTagsAsync(string taskId, IReadOnlyList<string> tagNames, CancellationToken ct = default)
{
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null) return;
task.Tags.Clear();
foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase))
{
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
if (tag is null)
{
tag = new TagEntity { Name = name };
_context.Tags.Add(tag);
}
task.Tags.Add(tag);
}
await _context.SaveChangesAsync(ct);
}
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
{
return await _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags)
.ToListAsync(ct);
}
public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default)
{
var taskTags = _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags);
var listTags = _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.List.Tags);
return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
}
#endregion
#region Planning #region Planning
public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default) public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default)
@@ -254,7 +186,6 @@ public sealed class TaskRepository
string parentId, string parentId,
string title, string title,
string? description, string? description,
IReadOnlyList<string>? tagNames,
string? commitType, string? commitType,
CancellationToken ct = default) CancellationToken ct = default)
{ {
@@ -286,22 +217,6 @@ public sealed class TaskRepository
SortOrder = (maxSort ?? -1) + 1, SortOrder = (maxSort ?? -1) + 1,
}; };
_context.Tasks.Add(child); _context.Tasks.Add(child);
if (tagNames is not null && tagNames.Count > 0)
{
foreach (var tagName in tagNames.Distinct(StringComparer.OrdinalIgnoreCase))
{
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == tagName, ct);
if (tag is null)
{
tag = new TagEntity { Name = tagName };
_context.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
}
child.Tags.Add(tag);
}
}
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
return child; return child;
} }
@@ -311,11 +226,10 @@ public sealed class TaskRepository
string? title, string? title,
string? description, string? description,
string? commitType, string? commitType,
IReadOnlyList<string>? tagNames,
TaskStatus? status, TaskStatus? status,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct) var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found."); ?? throw new InvalidOperationException($"Task {taskId} not found.");
if (title is not null) task.Title = title; if (title is not null) task.Title = title;
@@ -323,21 +237,6 @@ public sealed class TaskRepository
if (commitType is not null) task.CommitType = commitType; if (commitType is not null) task.CommitType = commitType;
if (status.HasValue) task.Status = status.Value; if (status.HasValue) task.Status = status.Value;
if (tagNames is not null)
{
task.Tags.Clear();
foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase))
{
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
if (tag is null)
{
tag = new TagEntity { Name = name };
_context.Tags.Add(tag);
}
task.Tags.Add(tag);
}
}
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }

View File

@@ -32,8 +32,6 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<ListConfigDto?> GetListConfigAsync(string listId); Task<ListConfigDto?> GetListConfigAsync(string listId);
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto); Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
Task SetTaskStatusAsync(string taskId, TaskStatus status); Task SetTaskStatusAsync(string taskId, TaskStatus status);
Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames);
Task<List<string>> GetAllTagsAsync();
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default); Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default); Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default); Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);

View File

@@ -395,23 +395,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
} }
public async Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames)
{
await _hub.InvokeAsync("SetTaskTags", taskId, tagNames.ToArray());
}
public async Task<List<string>> GetAllTagsAsync()
{
try
{
return await _hub.InvokeAsync<List<string>>("GetAllTags") ?? new List<string>();
}
catch
{
return new List<string>();
}
}
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync() public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync()
{ {
try try

View File

@@ -21,7 +21,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Current task row (set by IslandsShellViewModel via Bind) // Current task row (set by IslandsShellViewModel via Bind)
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))] [NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
private TaskRowViewModel? _task; private TaskRowViewModel? _task;
// Editable fields // Editable fields
@@ -56,74 +58,23 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Short task-id badge, e.g. "#T1A" // Short task-id badge, e.g. "#T1A"
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : ""; public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
// Agent strip fields
// Status editor (Details panel) — set freely; broadcast refreshes other panes.
public System.Collections.ObjectModel.ObservableCollection<ClaudeDo.Data.Models.TaskStatus> StatusOptions { get; } = new()
{
ClaudeDo.Data.Models.TaskStatus.Idle,
ClaudeDo.Data.Models.TaskStatus.Queued,
ClaudeDo.Data.Models.TaskStatus.Running,
ClaudeDo.Data.Models.TaskStatus.Done,
ClaudeDo.Data.Models.TaskStatus.Failed,
ClaudeDo.Data.Models.TaskStatus.Cancelled,
};
private bool _suppressStatusSave;
[ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _selectedStatus;
partial void OnSelectedStatusChanged(ClaudeDo.Data.Models.TaskStatus value)
{
if (_suppressStatusSave || Task is null) return;
_ = SaveStatusAsync(value);
}
private async System.Threading.Tasks.Task SaveStatusAsync(ClaudeDo.Data.Models.TaskStatus value)
{
if (Task is null) return;
try { await _worker.SetTaskStatusAsync(Task.Id, value); }
catch { /* offline */ }
}
// Tag editor
public ObservableCollection<string> Tags { get; } = new();
public ObservableCollection<string> AvailableTags { get; } = new();
[ObservableProperty] private string _newTagInput = "";
[RelayCommand]
private async System.Threading.Tasks.Task AddTagAsync()
{
if (Task is null) return;
var name = NewTagInput?.Trim().ToLowerInvariant();
NewTagInput = "";
if (string.IsNullOrEmpty(name)) return;
if (Tags.Contains(name)) return;
var next = Tags.ToList();
next.Add(name);
try { await _worker.SetTaskTagsAsync(Task.Id, next); }
catch { /* offline */ }
}
[RelayCommand]
private async System.Threading.Tasks.Task RemoveTagAsync(string? tagName)
{
if (Task is null || string.IsNullOrWhiteSpace(tagName)) return;
if (!Tags.Contains(tagName)) return;
var next = Tags.Where(t => t != tagName).ToList();
try { await _worker.SetTaskTagsAsync(Task.Id, next); }
catch { /* offline */ }
}
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))] [NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
private string _agentStatusLabel = "Idle"; private string _agentStatusLabel = "Idle";
public bool IsIdle => AgentStatusLabel == "Idle";
public bool IsQueued => AgentStatusLabel == "Queued";
public bool IsRunning => AgentStatusLabel == "Running"; public bool IsRunning => AgentStatusLabel == "Running";
public bool IsDone => AgentStatusLabel == "Done"; public bool IsDone => AgentStatusLabel == "Done";
public bool IsFailed => AgentStatusLabel == "Failed"; public bool IsFailed => AgentStatusLabel == "Failed";
public bool IsCancelled => AgentStatusLabel == "Cancelled";
[ObservableProperty] // Recovery actions: Continue (resume session) for Failed/Cancelled.
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))] public bool ShowContinue => IsFailed || IsCancelled;
[NotifyCanExecuteChangedFor(nameof(ResetCommand))] // Reset & retry available from any terminal state.
private bool _showFailedActions; public bool ShowResetAndRetry => IsFailed || IsCancelled || IsDone;
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))] [NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
@@ -131,11 +82,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
partial void OnAgentStatusLabelChanged(string value) partial void OnAgentStatusLabelChanged(string value)
{ {
OnPropertyChanged(nameof(IsIdle));
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsDone)); OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsFailed)); OnPropertyChanged(nameof(IsFailed));
OnPropertyChanged(nameof(IsCancelled));
OnPropertyChanged(nameof(ShowContinue));
OnPropertyChanged(nameof(ShowResetAndRetry));
OnPropertyChanged(nameof(IsAgentSectionEnabled)); OnPropertyChanged(nameof(IsAgentSectionEnabled));
ShowFailedActions = value == "Failed";
} }
[ObservableProperty] private string? _model; [ObservableProperty] private string? _model;
@@ -237,40 +192,17 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Set by the view so DeleteTaskCommand can show an error message // Set by the view so DeleteTaskCommand can show an error message
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; } public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
private void ApplyTagsFromEntity(ClaudeDo.Data.Models.TaskEntity entity) private async System.Threading.Tasks.Task RefreshStatusAsync(string taskId)
{
Tags.Clear();
foreach (var t in entity.Tags) Tags.Add(t.Name);
}
private async System.Threading.Tasks.Task RefreshAvailableTagsAsync()
{
try
{
var all = await _worker.GetAllTagsAsync();
AvailableTags.Clear();
foreach (var t in all) AvailableTags.Add(t);
}
catch { }
}
private async System.Threading.Tasks.Task RefreshTagsAndStatusAsync(string taskId)
{ {
try try
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks var entity = await ctx.Tasks
.AsNoTracking() .AsNoTracking()
.Include(t => t.Tags)
.FirstOrDefaultAsync(t => t.Id == taskId); .FirstOrDefaultAsync(t => t.Id == taskId);
if (entity is null || Task?.Id != taskId) return; if (entity is null || Task?.Id != taskId) return;
_suppressStatusSave = true;
try { SelectedStatus = entity.Status; }
finally { _suppressStatusSave = false; }
AgentStatusLabel = entity.Status.ToString(); AgentStatusLabel = entity.Status.ToString();
ApplyTagsFromEntity(entity);
await RefreshAvailableTagsAsync();
} }
catch { } catch { }
} }
@@ -289,9 +221,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
{ {
if (e.PropertyName == nameof(WorkerClient.IsConnected)) if (e.PropertyName == nameof(WorkerClient.IsConnected))
{ {
RunNowCommand.NotifyCanExecuteChanged(); EnqueueCommand.NotifyCanExecuteChanged();
DequeueCommand.NotifyCanExecuteChanged();
ResetAndRetryCommand.NotifyCanExecuteChanged();
ContinueCommand.NotifyCanExecuteChanged(); ContinueCommand.NotifyCanExecuteChanged();
ResetCommand.NotifyCanExecuteChanged();
ApproveMergeCommand.NotifyCanExecuteChanged(); ApproveMergeCommand.NotifyCanExecuteChanged();
} }
}; };
@@ -323,7 +256,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
_worker.TaskUpdatedEvent += taskId => _worker.TaskUpdatedEvent += taskId =>
{ {
if (Task?.Id == taskId) _ = RefreshTagsAndStatusAsync(taskId); if (Task?.Id == taskId) _ = RefreshStatusAsync(taskId);
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId); if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
}; };
@@ -503,13 +436,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
BranchLine = null; BranchLine = null;
AgentStatusLabel = "Idle"; AgentStatusLabel = "Idle";
LatestRunSessionId = null; LatestRunSessionId = null;
ShowFailedActions = false;
Tags.Clear();
AvailableTags.Clear();
NewTagInput = "";
_suppressStatusSave = true;
try { SelectedStatus = ClaudeDo.Data.Models.TaskStatus.Idle; }
finally { _suppressStatusSave = false; }
_suppressAgentSave = true; _suppressAgentSave = true;
try try
{ {
@@ -537,11 +463,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var subtaskRepo = new SubtaskRepository(ctx); var subtaskRepo = new SubtaskRepository(ctx);
// Own query with Include so WorktreePath/BranchLine/Tags are populated. // Own query with Include so WorktreePath/BranchLine are populated.
var entity = await ctx.Tasks var entity = await ctx.Tasks
.AsNoTracking() .AsNoTracking()
.Include(t => t.Worktree) .Include(t => t.Worktree)
.Include(t => t.Tags)
.FirstOrDefaultAsync(t => t.Id == row.Id, ct); .FirstOrDefaultAsync(t => t.Id == row.Id, ct);
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
if (entity == null) return; if (entity == null) return;
@@ -557,11 +482,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
WorktreeStateLabel = entity.Worktree?.State.ToString(); WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null; BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
AgentStatusLabel = entity.Status.ToString(); AgentStatusLabel = entity.Status.ToString();
_suppressStatusSave = true;
try { SelectedStatus = entity.Status; }
finally { _suppressStatusSave = false; }
ApplyTagsFromEntity(entity);
await RefreshAvailableTagsAsync();
await LoadAgentSettingsAsync(entity, ct); await LoadAgentSettingsAsync(entity, ct);
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
@@ -926,24 +846,35 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
await _worker.CancelTaskAsync(Task.Id); await _worker.CancelTaskAsync(Task.Id);
} }
[RelayCommand(CanExecute = nameof(CanRunNow))] [RelayCommand(CanExecute = nameof(CanEnqueue))]
private async System.Threading.Tasks.Task RunNowAsync() private async System.Threading.Tasks.Task EnqueueAsync()
{ {
if (Task == null) return; if (Task == null) return;
AgentStatusLabel = "Running";
try try
{ {
await _worker.RunNowAsync(Task.Id); await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
} AgentStatusLabel = "Queued";
catch
{
AgentStatusLabel = "Failed";
throw;
} }
catch { /* offline */ }
} }
private bool CanRunNow() => private bool CanEnqueue() =>
Task != null && _worker.IsConnected && !IsRunning; Task != null && _worker.IsConnected && IsIdle;
[RelayCommand(CanExecute = nameof(CanDequeue))]
private async System.Threading.Tasks.Task DequeueAsync()
{
if (Task == null) return;
try
{
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Idle);
AgentStatusLabel = "Idle";
}
catch { /* offline */ }
}
private bool CanDequeue() =>
Task != null && _worker.IsConnected && IsQueued;
[RelayCommand(CanExecute = nameof(CanContinue))] [RelayCommand(CanExecute = nameof(CanContinue))]
private async System.Threading.Tasks.Task ContinueAsync() private async System.Threading.Tasks.Task ContinueAsync()
@@ -953,23 +884,32 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
} }
private bool CanContinue() => private bool CanContinue() =>
Task != null && _worker.IsConnected && ShowFailedActions && !string.IsNullOrEmpty(LatestRunSessionId); Task != null && _worker.IsConnected && ShowContinue && !string.IsNullOrEmpty(LatestRunSessionId);
[RelayCommand(CanExecute = nameof(CanReset))] [RelayCommand(CanExecute = nameof(CanResetAndRetry))]
private async System.Threading.Tasks.Task ResetAsync() private async System.Threading.Tasks.Task ResetAndRetryAsync()
{ {
if (Task == null) return; if (Task == null) return;
if (ConfirmAsync == null) return; if (ConfirmAsync == null) return;
var branchName = $"claudedo/{Task.Id.Replace("-", "")}"; var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
var ok = await ConfirmAsync($"Discard worktree and reset task?\nThis deletes branch {branchName} and all uncommitted changes."); var ok = await ConfirmAsync(
$"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning.");
if (!ok) return; if (!ok) return;
if (WorktreePath != null)
await _worker.ResetTaskAsync(Task.Id); await _worker.ResetTaskAsync(Task.Id);
try
{
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
AgentStatusLabel = "Queued";
}
catch { /* offline */ }
} }
private bool CanReset() => private bool CanResetAndRetry() =>
Task != null && _worker.IsConnected && ShowFailedActions; Task != null && _worker.IsConnected && ShowResetAndRetry;
} }
public sealed partial class SubtaskRowViewModel : ViewModelBase public sealed partial class SubtaskRowViewModel : ViewModelBase

View File

@@ -1,5 +1,3 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -8,11 +6,6 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class TaskRowViewModel : ViewModelBase public sealed partial class TaskRowViewModel : ViewModelBase
{ {
public TaskRowViewModel()
{
Tags.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTags));
}
public required string Id { get; init; } public required string Id { get; init; }
[ObservableProperty] private string _title = ""; [ObservableProperty] private string _title = "";
[ObservableProperty] private string _listName = ""; [ObservableProperty] private string _listName = "";
@@ -39,7 +32,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public DateTime CreatedAt { get; init; } public DateTime CreatedAt { get; init; }
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}"; public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
public ObservableCollection<string> Tags { get; } = new();
public int StepsCount { get; init; } public int StepsCount { get; init; }
public int StepsCompleted { get; init; } public int StepsCompleted { get; init; }
@@ -62,13 +54,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch); public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0; public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
public bool HasTags => Tags.Count > 0;
public bool HasSteps => StepsCount > 0; public bool HasSteps => StepsCount > 0;
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done; public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
public bool IsRunning => Status == TaskStatus.Running; public bool IsRunning => Status == TaskStatus.Running;
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId); public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId); public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks; public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks;
public bool HasSchedule => ScheduledFor.HasValue; public bool HasSchedule => ScheduledFor.HasValue;
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail); public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
@@ -96,6 +88,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(IsDraft)); OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(CanOpenPlanningSession)); OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanRemoveFromQueue)); OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
} }
partial void OnPlanningPhaseChanged(PlanningPhase value) partial void OnPlanningPhaseChanged(PlanningPhase value)
@@ -107,7 +100,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
} }
partial void OnHasQueuedSubtasksChanged(bool value) partial void OnHasQueuedSubtasksChanged(bool value)
=> OnPropertyChanged(nameof(CanRemoveFromQueue)); {
OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
}
partial void OnBlockedByTaskIdChanged(string? value) partial void OnBlockedByTaskIdChanged(string? value)
{ {
@@ -160,15 +156,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
DiffDeletions = del; DiffDeletions = del;
ParentTaskId = t.ParentTaskId; ParentTaskId = t.ParentTaskId;
BlockedByTaskId = t.BlockedByTaskId; BlockedByTaskId = t.BlockedByTaskId;
SetTags(t.Tags.Select(tag => tag.Name));
}
public void SetTags(IEnumerable<string> names)
{
var snapshot = names.ToList();
if (Tags.SequenceEqual(snapshot)) return;
Tags.Clear();
foreach (var n in snapshot) Tags.Add(n);
} }
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions". // Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".

View File

@@ -31,7 +31,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new(); public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new(); public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new(); public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();
public ObservableCollection<string> AllTags { get; } = new();
[ObservableProperty] private string _newTaskTitle = ""; [ObservableProperty] private string _newTaskTitle = "";
[ObservableProperty] private TaskRowViewModel? _selectedTask; [ObservableProperty] private TaskRowViewModel? _selectedTask;
@@ -59,22 +58,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated; _worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
_worker.TaskMessageEvent += OnWorkerTaskMessage; _worker.TaskMessageEvent += OnWorkerTaskMessage;
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList); _worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
_ = RefreshAllTagsAsync();
} }
} }
private async Task RefreshAllTagsAsync()
{
if (_worker is null) return;
try
{
var tags = await _worker.GetAllTagsAsync();
AllTags.Clear();
foreach (var t in tags) AllTags.Add(t);
}
catch { /* offline */ }
}
private void OnWorkerTaskMessage(string taskId, string line) private void OnWorkerTaskMessage(string taskId, string line)
{ {
var row = Items.FirstOrDefault(r => r.Id == taskId); var row = Items.FirstOrDefault(r => r.Id == taskId);
@@ -101,7 +87,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
var entity = await db.Tasks var entity = await db.Tasks
.Include(t => t.List) .Include(t => t.List)
.Include(t => t.Worktree) .Include(t => t.Worktree)
.Include(t => t.Tags)
.FirstOrDefaultAsync(t => t.Id == taskId); .FirstOrDefaultAsync(t => t.Id == taskId);
var existing = Items.FirstOrDefault(r => r.Id == taskId); var existing = Items.FirstOrDefault(r => r.Id == taskId);
@@ -190,7 +175,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
var all = await db.Tasks var all = await db.Tasks
.Include(t => t.List) .Include(t => t.List)
.Include(t => t.Worktree) .Include(t => t.Worktree)
.Include(t => t.Tags)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);
@@ -484,37 +468,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
catch { /* offline; broadcast won't fire */ } catch { /* offline; broadcast won't fire */ }
} }
public async Task ToggleTagOnRowAsync(TaskRowViewModel row, string tagName)
{
if (_worker is null) return;
var name = tagName.Trim().ToLowerInvariant();
if (name.Length == 0) return;
var current = row.Tags.ToList();
var next = current.Contains(name)
? current.Where(t => t != name).ToList()
: current.Append(name).ToList();
try
{
await _worker.SetTaskTagsAsync(row.Id, next);
await RefreshAllTagsAsync();
}
catch { }
}
[RelayCommand] [RelayCommand]
private async Task SendToQueueAsync(TaskRowViewModel? row) private async Task SendToQueueAsync(TaskRowViewModel? row)
{ {
if (row is null || row.IsRunning) return; if (row is null || row.IsRunning) return;
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == row.Id); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity is null) return; if (entity is null) return;
entity.Status = TaskStatus.Queued; entity.Status = TaskStatus.Queued;
// Worker queue picker requires the "agent" tag — attach it on explicit enqueue.
if (!entity.Tags.Any(t => t.Name == "agent"))
{
var agentTag = await db.Tags.FirstOrDefaultAsync(t => t.Name == "agent");
if (agentTag is not null) entity.Tags.Add(agentTag);
}
await db.SaveChangesAsync(); await db.SaveChangesAsync();
row.Status = TaskStatus.Queued; row.Status = TaskStatus.Queued;
if (_worker is not null) if (_worker is not null)
@@ -568,6 +529,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
TasksChanged?.Invoke(this, EventArgs.Empty); TasksChanged?.Invoke(this, EventArgs.Empty);
} }
[RelayCommand]
private async Task CancelRunningTaskAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsRunning || _worker is null) return;
try { await _worker.CancelTaskAsync(row.Id); }
catch { /* worker offline; the broadcast will reconcile when it returns */ }
}
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when) public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
{ {
if (row is null) return; if (row is null) return;

View File

@@ -42,13 +42,22 @@
<PathIcon Data="{StaticResource Icon.X}" Width="12" Height="12" <PathIcon Data="{StaticResource Icon.X}" Width="12" Height="12"
Foreground="{DynamicResource BloodBrush}"/> Foreground="{DynamicResource BloodBrush}"/>
</Button> </Button>
<!-- Hand off button — only when idle --> <!-- Send to queue — only when idle -->
<Button Grid.Column="3" <Button Grid.Column="3"
Classes="btn accent" Classes="btn accent"
Content="Hand off" Content="Send to queue"
Command="{Binding RunNowCommand}" Command="{Binding EnqueueCommand}"
IsVisible="{Binding !IsRunning}" IsVisible="{Binding IsIdle}"
ToolTip.Tip="Hand task off to Claude" ToolTip.Tip="Queue this task for the worker to pick up"
VerticalAlignment="Center"
Padding="10,4"/>
<!-- Remove from queue — only when queued -->
<Button Grid.Column="3"
Classes="btn"
Content="Remove from queue"
Command="{Binding DequeueCommand}"
IsVisible="{Binding IsQueued}"
ToolTip.Tip="Take this task back out of the queue"
VerticalAlignment="Center" VerticalAlignment="Center"
Padding="10,4"/> Padding="10,4"/>
</Grid> </Grid>
@@ -144,14 +153,14 @@
<Button Classes="btn accent" <Button Classes="btn accent"
Content="Continue" Content="Continue"
Command="{Binding ContinueCommand}" Command="{Binding ContinueCommand}"
IsVisible="{Binding ShowFailedActions}" IsVisible="{Binding ShowContinue}"
ToolTip.Tip="Resume the task from where it failed" ToolTip.Tip="Resume the last session and keep going"
Padding="10,4"/> Padding="10,4"/>
<Button Classes="btn" <Button Classes="btn"
Content="Reset" Content="Reset &amp; retry"
Command="{Binding ResetCommand}" Command="{Binding ResetAndRetryCommand}"
IsVisible="{Binding ShowFailedActions}" IsVisible="{Binding ShowResetAndRetry}"
ToolTip.Tip="Discard the worktree and move the task back to Manual" ToolTip.Tip="Discard the worktree and re-queue the task to run from scratch"
Padding="10,4"/> Padding="10,4"/>
</StackPanel> </StackPanel>

View File

@@ -35,36 +35,39 @@
</Grid> </Grid>
</Border> </Border>
<!-- ── Header (sticky top): eyebrow · title · gear (agent-settings flyout) ── --> <!-- ── Header (sticky top): check · eyebrow · title · status · star · gear ── -->
<Border DockPanel.Dock="Top" Classes="island-header"> <Border DockPanel.Dock="Top" Classes="island-header">
<Grid ColumnDefinitions="*,Auto,Auto"> <Grid ColumnDefinitions="Auto,*,Auto,Auto">
<StackPanel Grid.Column="0" Spacing="0"> <Ellipse Grid.Column="0"
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,4"> Classes="task-check"
<Ellipse Width="5" Height="5" Fill="{DynamicResource AccentBrush}" Classes.done="{Binding Task.Done}"
VerticalAlignment="Center"/> Width="18" Height="18"
<TextBlock Classes="eyebrow" Text="LOGBOOK" VerticalAlignment="Center"/> VerticalAlignment="Top"
Margin="0,2,10,0"
Cursor="Hand"/>
<StackPanel Grid.Column="1" Spacing="0">
<TextBlock Text="{Binding TaskIdBadge}" <TextBlock Text="{Binding TaskIdBadge}"
FontFamily="{DynamicResource MonoFont}" FontSize="10" FontFamily="{DynamicResource MonoFont}" FontSize="10"
Foreground="{DynamicResource TextFaintBrush}" Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center" Margin="0,0,0,4"/>
Margin="8,0,0,0"/>
</StackPanel>
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}" <TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
FontSize="14" FontWeight="Medium" FontSize="14" FontWeight="Medium"
BorderThickness="0" Background="Transparent" BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource TextBrush}" Foreground="{DynamicResource TextBrush}"
TextWrapping="Wrap"
AcceptsReturn="False"
Padding="0"/> Padding="0"/>
</StackPanel> </StackPanel>
<ComboBox Grid.Column="1" <Button Grid.Column="2"
ItemsSource="{Binding StatusOptions}" Classes="icon-btn star-btn"
SelectedItem="{Binding SelectedStatus, Mode=TwoWay}" Classes.on="{Binding Task.IsStarred}"
ToolTip.Tip="Set status (no transition guards)"
VerticalAlignment="Top" VerticalAlignment="Top"
MinWidth="110" Margin="6,0,0,0">
Margin="6,0,0,0"/> <PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
</Button>
<Button Grid.Column="2" Classes="icon-btn" <Button Grid.Column="3" Classes="icon-btn"
ToolTip.Tip="Agent settings" ToolTip.Tip="Agent settings"
IsEnabled="{Binding IsAgentSectionEnabled}" IsEnabled="{Binding IsAgentSectionEnabled}"
VerticalAlignment="Top" VerticalAlignment="Top"
@@ -112,34 +115,6 @@
</Grid> </Grid>
</Border> </Border>
<!-- ── Task strip row (sticky top): check + title + star ── -->
<Border DockPanel.Dock="Top"
Padding="18,10,18,10"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto">
<Ellipse Grid.Column="0"
Classes="task-check"
Classes.done="{Binding Task.Done}"
Width="18" Height="18"
VerticalAlignment="Center"
Cursor="Hand"/>
<TextBlock Grid.Column="1"
Text="{Binding EditableTitle}"
FontSize="14" FontWeight="Medium"
Foreground="{DynamicResource TextBrush}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
Margin="10,0"/>
<Button Grid.Column="2"
Classes="icon-btn star-btn"
Classes.on="{Binding Task.IsStarred}"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
</Button>
</Grid>
</Border>
<!-- ── Agent status strip (sticky, above metadata footer) ── --> <!-- ── Agent status strip (sticky, above metadata footer) ── -->
<islands:AgentStripView DockPanel.Dock="Bottom"/> <islands:AgentStripView DockPanel.Dock="Bottom"/>
@@ -147,46 +122,6 @@
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="0"> <StackPanel Spacing="0">
<!-- Tags section -->
<Border Padding="18,12,18,12"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1">
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="TAGS" Margin="0,0,0,2"/>
<ItemsControl ItemsSource="{Binding Tags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border Classes="chip chip-tag" Margin="0,0,6,4">
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<TextBlock Text="{Binding}" VerticalAlignment="Center"/>
<Button Classes="icon-btn"
Padding="2,0"
VerticalAlignment="Center"
ToolTip.Tip="Remove tag"
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).RemoveTagCommand}"
CommandParameter="{Binding}">
<TextBlock Text="×" FontSize="12"/>
</Button>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<AutoCompleteBox ItemsSource="{Binding AvailableTags}"
Text="{Binding NewTagInput, Mode=TwoWay}"
Watermark="Add tag (Enter to add)">
<AutoCompleteBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddTagCommand}"/>
</AutoCompleteBox.KeyBindings>
</AutoCompleteBox>
</StackPanel>
</Border>
<!-- Planning merge section — visible only for planning parent tasks --> <!-- Planning merge section — visible only for planning parent tasks -->
<Border Padding="18,12,18,12" <Border Padding="18,12,18,12"
BorderBrush="{DynamicResource LineBrush}" BorderBrush="{DynamicResource LineBrush}"

View File

@@ -31,23 +31,21 @@
Classes.selected="{Binding IsSelected}" Classes.selected="{Binding IsSelected}"
Classes.done="{Binding Done}"> Classes.done="{Binding Done}">
<Border.ContextMenu> <Border.ContextMenu>
<ContextMenu Opening="OnContextMenuOpening"> <ContextMenu>
<MenuItem Header="Send to queue" <MenuItem Header="Send to queue"
IsVisible="{Binding !IsQueued}" IsVisible="{Binding CanSendToQueue}"
Click="OnSendToQueueClick"/> Click="OnSendToQueueClick"/>
<MenuItem Header="Remove from queue" <MenuItem Header="Remove from queue"
IsVisible="{Binding CanRemoveFromQueue}" IsVisible="{Binding CanRemoveFromQueue}"
Click="OnRemoveFromQueueClick"/> Click="OnRemoveFromQueueClick"/>
<MenuItem Header="Cancel execution"
IsVisible="{Binding IsRunning}"
Click="OnCancelExecutionClick"/>
<Separator/> <Separator/>
<MenuItem Header="Set status"> <MenuItem Header="Mark as">
<MenuItem Header="Idle" Tag="Idle" Click="OnSetStatusClick"/>
<MenuItem Header="Queued" Tag="Queued" Click="OnSetStatusClick"/>
<MenuItem Header="Running" Tag="Running" Click="OnSetStatusClick"/>
<MenuItem Header="Done" Tag="Done" Click="OnSetStatusClick"/> <MenuItem Header="Done" Tag="Done" Click="OnSetStatusClick"/>
<MenuItem Header="Failed" Tag="Failed" Click="OnSetStatusClick"/>
<MenuItem Header="Cancelled" Tag="Cancelled" Click="OnSetStatusClick"/> <MenuItem Header="Cancelled" Tag="Cancelled" Click="OnSetStatusClick"/>
</MenuItem> </MenuItem>
<MenuItem Header="Tags" x:Name="TagsMenu"/>
<Separator/> <Separator/>
<MenuItem Header="Run interactively" <MenuItem Header="Run interactively"
Click="OnRunInteractivelyClick"/> Click="OnRunInteractivelyClick"/>
@@ -99,16 +97,19 @@
<!-- Title + chip row + live tail --> <!-- Title + chip row + live tail -->
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center"> <StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center"> <Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Classes="task-title" <TextBlock Grid.Column="0"
Classes="task-title"
Text="{Binding Title}" FontSize="14" Text="{Binding Title}" FontSize="14"
Foreground="{DynamicResource TextBrush}" Foreground="{DynamicResource TextBrush}"
TextWrapping="Wrap"
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}" FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}" Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/> TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
<!-- Badges: DRAFT and planning session --> <!-- Badges: DRAFT and planning session -->
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
VerticalAlignment="Center" Margin="4,0,0,0">
<Border Classes="badge draft" IsVisible="{Binding IsDraft}"> <Border Classes="badge draft" IsVisible="{Binding IsDraft}">
<TextBlock Text="DRAFT"/> <TextBlock Text="DRAFT"/>
</Border> </Border>
@@ -116,7 +117,7 @@
<TextBlock Text="{Binding PlanningBadge}"/> <TextBlock Text="{Binding PlanningBadge}"/>
</Border> </Border>
</StackPanel> </StackPanel>
</StackPanel> </Grid>
<!-- Chip row --> <!-- Chip row -->
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
@@ -167,21 +168,6 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Tag chips -->
<ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="6"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Classes="chip chip-tag">
<TextBlock Text="{Binding}"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel> </StackPanel>
<!-- Live-tail row (visible when running + has tail) --> <!-- Live-tail row (visible when running + has tail) -->

View File

@@ -36,6 +36,12 @@ public partial class TaskRowView : UserControl
await vm.RemoveFromQueueCommand.ExecuteAsync(row); await vm.RemoveFromQueueCommand.ExecuteAsync(row);
} }
private async void OnCancelExecutionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.CancelRunningTaskCommand.ExecuteAsync(row);
}
private async void OnClearScheduleClick(object? sender, RoutedEventArgs e) private async void OnClearScheduleClick(object? sender, RoutedEventArgs e)
{ {
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
@@ -82,37 +88,6 @@ public partial class TaskRowView : UserControl
await vm.SetStatusOnRowAsync(row, status); await vm.SetStatusOnRowAsync(row, status);
} }
private void OnContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
{
if (DataContext is not TaskRowViewModel row || FindTasksVm() is not { } vm) return;
// Build the union of all known tags + tags currently on this row, so a row's
// own tags stay reachable from the menu even if the global list is stale.
var rowTags = row.Tags.ToHashSet();
var union = vm.AllTags.Concat(rowTags).Distinct().OrderBy(t => t).ToList();
TagsMenu.Items.Clear();
if (union.Count == 0)
{
TagsMenu.Items.Add(new MenuItem { Header = "(no tags yet)", IsEnabled = false });
return;
}
foreach (var name in union)
{
var prefix = rowTags.Contains(name) ? "✓ " : " ";
var item = new MenuItem { Header = prefix + name, Tag = name };
item.Click += OnToggleTagClick;
TagsMenu.Items.Add(item);
}
}
private async void OnToggleTagClick(object? sender, RoutedEventArgs e)
{
if (sender is not MenuItem mi || mi.Tag is not string name) return;
if (DataContext is not TaskRowViewModel row || FindTasksVm() is not { } vm) return;
await vm.ToggleTagOnRowAsync(row, name);
}
private void OnScheduleForClick(object? sender, RoutedEventArgs e) private void OnScheduleForClick(object? sender, RoutedEventArgs e)
{ {
if (DataContext is not TaskRowViewModel row) return; if (DataContext is not TaskRowViewModel row) return;

View File

@@ -151,10 +151,10 @@
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*" MinWidth="320"/> <ColumnDefinition Width="*" MinWidth="320"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="320" MinWidth="280"/> <ColumnDefinition Width="460" MinWidth="280"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Border Grid.Column="0" Classes="island" Margin="7"> <Border Grid.Column="0" Classes="island" Margin="3">
<islands:ListsIslandView DataContext="{Binding Lists}"/> <islands:ListsIslandView DataContext="{Binding Lists}"/>
</Border> </Border>
@@ -166,7 +166,7 @@
ResizeDirection="Columns" ResizeDirection="Columns"
ResizeBehavior="PreviousAndNext"/> ResizeBehavior="PreviousAndNext"/>
<Border Grid.Column="2" Classes="island" Margin="7"> <Border Grid.Column="2" Classes="island" Margin="3">
<islands:TasksIslandView DataContext="{Binding Tasks}"/> <islands:TasksIslandView DataContext="{Binding Tasks}"/>
</Border> </Border>
@@ -179,7 +179,7 @@
ResizeBehavior="PreviousAndNext" ResizeBehavior="PreviousAndNext"
IsVisible="{Binding ShowDetails}"/> IsVisible="{Binding ShowDetails}"/>
<Border Grid.Column="4" Classes="island" Margin="7" <Border Grid.Column="4" Classes="island" Margin="3"
IsVisible="{Binding ShowDetails}"> IsVisible="{Binding ShowDetails}">
<islands:DetailsIslandView DataContext="{Binding Details}"/> <islands:DetailsIslandView DataContext="{Binding Details}"/>
</Border> </Border>

View File

@@ -11,8 +11,6 @@ namespace ClaudeDo.Worker.External;
public sealed record TaskListDto(string Id, string Name, string? WorkingDir); public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
public sealed record TagDto(long Id, string Name);
public sealed record TaskDto( public sealed record TaskDto(
string Id, string Id,
string ListId, string ListId,
@@ -32,7 +30,6 @@ public sealed class ExternalMcpService
private readonly ListRepository _lists; private readonly ListRepository _lists;
private readonly QueueService _queue; private readonly QueueService _queue;
private readonly HubBroadcaster _broadcaster; private readonly HubBroadcaster _broadcaster;
private readonly TagRepository _tags;
private readonly ITaskStateService _state; private readonly ITaskStateService _state;
public ExternalMcpService( public ExternalMcpService(
@@ -40,14 +37,12 @@ public sealed class ExternalMcpService
ListRepository lists, ListRepository lists,
QueueService queue, QueueService queue,
HubBroadcaster broadcaster, HubBroadcaster broadcaster,
TagRepository tags,
ITaskStateService state) ITaskStateService state)
{ {
_tasks = tasks; _tasks = tasks;
_lists = lists; _lists = lists;
_queue = queue; _queue = queue;
_broadcaster = broadcaster; _broadcaster = broadcaster;
_tags = tags;
_state = state; _state = state;
} }
@@ -91,14 +86,13 @@ public sealed class ExternalMcpService
return ToDto(task); return ToDto(task);
} }
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. Optional tags are attached on creation; missing tag names auto-create.")] [McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution.")]
public async Task<TaskDto> AddTask( public async Task<TaskDto> AddTask(
string listId, string listId,
string title, string title,
string? description, string? description,
string createdBy, string createdBy,
bool queueImmediately, bool queueImmediately,
IReadOnlyList<string>? tags,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(listId)) if (string.IsNullOrWhiteSpace(listId))
@@ -124,9 +118,6 @@ public sealed class ExternalMcpService
}; };
await _tasks.AddAsync(entity, cancellationToken); await _tasks.AddAsync(entity, cancellationToken);
if (tags is not null && tags.Count > 0)
await _tasks.SetTagsAsync(entity.Id, tags, cancellationToken);
if (queueImmediately) if (queueImmediately)
{ {
// Routes through TaskStateService so the queue is woken automatically. // Routes through TaskStateService so the queue is woken automatically.
@@ -140,13 +131,12 @@ public sealed class ExternalMcpService
return ToDto(entity); return ToDto(entity);
} }
[McpServerTool, Description("Update an existing task's title, description, commit type, and/or tags. Pass null to leave a field unchanged. Tags are replaced as a full set when non-null. Refuses if the task is currently Running.")] [McpServerTool, Description("Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. Refuses if the task is currently Running.")]
public async Task<TaskDto> UpdateTask( public async Task<TaskDto> UpdateTask(
string taskId, string taskId,
string? title, string? title,
string? description, string? description,
string? commitType, string? commitType,
IReadOnlyList<string>? tags,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var task = await _tasks.GetByIdAsync(taskId, cancellationToken) var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
@@ -159,9 +149,6 @@ public sealed class ExternalMcpService
if (commitType is not null) task.CommitType = commitType; if (commitType is not null) task.CommitType = commitType;
await _tasks.UpdateAsync(task, cancellationToken); await _tasks.UpdateAsync(task, cancellationToken);
if (tags is not null)
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);
return ToDto(reload); return ToDto(reload);
@@ -239,30 +226,6 @@ public sealed class ExternalMcpService
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);
} }
[McpServerTool, Description("Replace the full tag set on an existing task. Missing tag names auto-create. Refuses if the task is Running.")]
public async Task<TaskDto> SetTaskTags(
string taskId,
IReadOnlyList<string> tags,
CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot retag a running task. Cancel it first.");
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
return ToDto(reload);
}
[McpServerTool, Description("List all known tags. Useful for discovering existing tag names (including 'agent' which marks tasks for auto-execution) before tagging.")]
public async Task<IReadOnlyList<TagDto>> ListTags(CancellationToken cancellationToken)
{
var tags = await _tags.GetAllAsync(cancellationToken);
return tags.Select(t => new TagDto(t.Id, t.Name)).ToList();
}
private static TaskDto ToDto(TaskEntity t) => new( private static TaskDto ToDto(TaskEntity t) => new(
t.Id, t.Id,
t.ListId, t.ListId,

View File

@@ -331,41 +331,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed"); if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
} }
public async Task SetTaskTags(string taskId, string[] tagNames)
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId);
if (entity is null) throw new HubException("task not found");
var desired = (tagNames ?? Array.Empty<string>())
.Select(n => n?.Trim().ToLowerInvariant() ?? "")
.Where(n => n.Length > 0)
.ToHashSet();
foreach (var t in entity.Tags.Where(t => !desired.Contains(t.Name)).ToList())
entity.Tags.Remove(t);
var existingByName = await ctx.Tags
.Where(t => desired.Contains(t.Name))
.ToListAsync();
foreach (var name in desired)
{
if (entity.Tags.Any(t => t.Name == name)) continue;
var tag = existingByName.FirstOrDefault(t => t.Name == name)
?? new TagEntity { Name = name };
if (tag.Id == 0) ctx.Tags.Add(tag);
entity.Tags.Add(tag);
}
await ctx.SaveChangesAsync();
await _broadcaster.TaskUpdated(taskId);
}
public async Task<List<string>> GetAllTags()
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
return await ctx.Tags.OrderBy(t => t.Name).Select(t => t.Name).ToListAsync();
}
public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto) public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)
{ {
using var ctx = _dbFactory.CreateDbContext(); using var ctx = _dbFactory.CreateDbContext();

View File

@@ -28,7 +28,6 @@ public sealed class PlanningChainCoordinator
// chain leaves history alone but still reshapes the tail. // chain leaves history alone but still reshapes the tail.
// - Running children abort the operation — the chain cannot be reshaped while // - Running children abort the operation — the chain cannot be reshaped while
// one of its members is mid-flight. // one of its members is mid-flight.
// The "agent" tag is auto-attached to every child so the picker can claim them.
// Returns the number of children placed in the chain. // Returns the number of children placed in the chain.
internal async Task<int> SetupChainAsync(string parentTaskId, CancellationToken ct = default) internal async Task<int> SetupChainAsync(string parentTaskId, CancellationToken ct = default)
{ {
@@ -37,7 +36,6 @@ public sealed class PlanningChainCoordinator
?? throw new InvalidOperationException($"Task {parentTaskId} not found."); ?? throw new InvalidOperationException($"Task {parentTaskId} not found.");
var children = await ctx.Tasks var children = await ctx.Tasks
.Include(t => t.Tags)
.Where(t => t.ParentTaskId == parentTaskId) .Where(t => t.ParentTaskId == parentTaskId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);
@@ -49,18 +47,6 @@ public sealed class PlanningChainCoordinator
throw new InvalidOperationException( throw new InvalidOperationException(
$"Child {running.Id} is running; cannot reshape chain."); $"Child {running.Id} is running; cannot reshape chain.");
// Worker queue picker requires the "agent" tag — attach it so children are pickable.
var agentTag = await ctx.Tags.FirstOrDefaultAsync(t => t.Name == "agent", ct);
if (agentTag is not null)
{
foreach (var c in children)
{
if (!c.Tags.Any(t => t.Id == agentTag.Id))
c.Tags.Add(agentTag);
}
await ctx.SaveChangesAsync(ct);
}
// Re-shape over Idle and Queued children only; leave Done/Failed/Cancelled // Re-shape over Idle and Queued children only; leave Done/Failed/Cancelled
// (terminal) results in place. // (terminal) results in place.
var sequenceable = children var sequenceable = children

View File

@@ -8,7 +8,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Planning; namespace ClaudeDo.Worker.Planning;
public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status, IReadOnlyList<string> Tags); public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status);
public sealed record CreatedChildDto(string TaskId, string Status); public sealed record CreatedChildDto(string TaskId, string Status);
[McpServerToolType] [McpServerToolType]
@@ -41,12 +41,11 @@ public sealed class PlanningMcpService
public async Task<CreatedChildDto> CreateChildTask( public async Task<CreatedChildDto> CreateChildTask(
string title, string title,
string? description, string? description,
IReadOnlyList<string>? tags,
string? commitType, string? commitType,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var ctx = _contextAccessor.Current; var ctx = _contextAccessor.Current;
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken); var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, commitType, cancellationToken);
await BroadcastTaskUpdatedAsync(child.Id, cancellationToken); await BroadcastTaskUpdatedAsync(child.Id, cancellationToken);
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
return new CreatedChildDto(child.Id, child.Status.ToString()); return new CreatedChildDto(child.Id, child.Status.ToString());
@@ -58,24 +57,19 @@ public sealed class PlanningMcpService
{ {
var ctx = _contextAccessor.Current; var ctx = _contextAccessor.Current;
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken); var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
var list = new List<ChildTaskDto>(children.Count); return children
foreach (var c in children) .Select(c => new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString()))
{ .ToList();
var tagList = await _tasks.GetTagsAsync(c.Id, cancellationToken);
list.Add(new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString(), tagList.Select(t => t.Name).ToList()));
}
return list;
} }
private static readonly TaskStatus[] EditableStatuses = private static readonly TaskStatus[] EditableStatuses =
{ TaskStatus.Idle, TaskStatus.Queued }; { TaskStatus.Idle, TaskStatus.Queued };
[McpServerTool, Description("Update a child task in the active planning session. Can change title, description, tags (replaces the full set), commit type, and status. Status must be one of: Idle, Queued.")] [McpServerTool, Description("Update a child task in the active planning session. Can change title, description, commit type, and status. Status must be one of: Idle, Queued.")]
public async Task<ChildTaskDto> UpdateChildTask( public async Task<ChildTaskDto> UpdateChildTask(
string taskId, string taskId,
string? title, string? title,
string? description, string? description,
IReadOnlyList<string>? tags,
string? commitType, string? commitType,
string? status, string? status,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -101,13 +95,12 @@ public sealed class PlanningMcpService
newStatus = parsed; newStatus = parsed;
} }
await _tasks.UpdateChildAsync(taskId, title, description, commitType, tags, newStatus, cancellationToken); await _tasks.UpdateChildAsync(taskId, title, description, commitType, newStatus, cancellationToken);
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken);
await BroadcastTaskUpdatedAsync(reload.Id, cancellationToken); await BroadcastTaskUpdatedAsync(reload.Id, cancellationToken);
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList()); return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString());
} }
[McpServerTool, Description("Delete a child task in the active planning session.")] [McpServerTool, Description("Delete a child task in the active planning session.")]

View File

@@ -185,7 +185,6 @@ if (cfg.ExternalMcpPort > 0)
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext()); sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
externalBuilder.Services.AddScoped<TaskRepository>(); externalBuilder.Services.AddScoped<TaskRepository>();
externalBuilder.Services.AddScoped<ListRepository>(); externalBuilder.Services.AddScoped<ListRepository>();
externalBuilder.Services.AddScoped<TagRepository>();
externalBuilder.Services.AddScoped<ExternalMcpService>(); externalBuilder.Services.AddScoped<ExternalMcpService>();
externalBuilder.Services.AddMcpServer() externalBuilder.Services.AddMcpServer()
.WithHttpTransport() .WithHttpTransport()

View File

@@ -362,19 +362,14 @@ public sealed class TaskRunner
TaskEntity task, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct) TaskEntity task, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct)
{ {
AppSettingsEntity global; AppSettingsEntity global;
bool isAgentTask;
using (var ctx = _dbFactory.CreateDbContext()) using (var ctx = _dbFactory.CreateDbContext())
{ {
var settingsRepo = new AppSettingsRepository(ctx); var settingsRepo = new AppSettingsRepository(ctx);
global = await settingsRepo.GetAsync(ct); global = await settingsRepo.GetAsync(ct);
var taskRepo = new TaskRepository(ctx);
var tags = await taskRepo.GetEffectiveTagsAsync(task.Id, ct);
isAgentTask = tags.Any(t => string.Equals(t.Name, "agent", StringComparison.OrdinalIgnoreCase));
} }
var systemFile = PromptFiles.ReadOrNull(PromptKind.System); var systemFile = PromptFiles.ReadOrNull(PromptKind.System);
var agentFile = isAgentTask ? PromptFiles.ReadOrNull(PromptKind.Agent) : null; var agentFile = PromptFiles.ReadOrNull(PromptKind.Agent);
var instructions = MergeInstructions( var instructions = MergeInstructions(
systemFile, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt, agentFile); systemFile, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt, agentFile);

View File

@@ -52,7 +52,6 @@ public sealed class ExternalMcpServiceTests : IDisposable
private readonly ClaudeDoDbContext _ctx; private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks; private readonly TaskRepository _tasks;
private readonly ListRepository _lists; private readonly ListRepository _lists;
private readonly TagRepository _tags;
private readonly ExternalFakeHubContext _hub = new(); private readonly ExternalFakeHubContext _hub = new();
private readonly HubBroadcaster _broadcaster; private readonly HubBroadcaster _broadcaster;
@@ -61,7 +60,6 @@ public sealed class ExternalMcpServiceTests : IDisposable
_ctx = _db.CreateContext(); _ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx); _tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx); _lists = new ListRepository(_ctx);
_tags = new TagRepository(_ctx);
_broadcaster = new HubBroadcaster(_hub); _broadcaster = new HubBroadcaster(_hub);
} }
@@ -89,12 +87,8 @@ public sealed class ExternalMcpServiceTests : IDisposable
return task; return task;
} }
// QueueService is needed by ExternalMcpService's constructor. For tests that
// only exercise UpdateTask / DeleteTask / SetTaskTags / ListTags / ListTags,
// we never call its WakeQueue/RunNow/CancelTask paths, so a real QueueService
// built with the same approach used in QueueServiceTests is sufficient.
private ExternalMcpService BuildSut(QueueService queue) => private ExternalMcpService BuildSut(QueueService queue) =>
new(_tasks, _lists, queue, _broadcaster, _tags, new(_tasks, _lists, queue, _broadcaster,
TaskStateServiceBuilder.Build(_db.CreateFactory()).State); TaskStateServiceBuilder.Build(_db.CreateFactory()).State);
private QueueService CreateQueue() private QueueService CreateQueue()
@@ -129,54 +123,6 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.NotNull(await _tasks.GetByIdAsync(task.Id)); Assert.NotNull(await _tasks.GetByIdAsync(task.Id));
} }
[Fact]
public async Task ListTags_ReturnsSeededAndCustomTags()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "custom-tag" });
var queue = CreateQueue();
var sut = BuildSut(queue);
var tags = await sut.ListTags(CancellationToken.None);
Assert.Contains(tags, t => t.Name == "agent");
Assert.Contains(tags, t => t.Name == "custom-tag");
}
[Fact]
public async Task AddTask_WithTags_AttachesTags()
{
var listId = await SeedListAsync();
var queue = CreateQueue();
var sut = BuildSut(queue);
var dto = await sut.AddTask(
listId, "scope-creep handoff", "desc", "claude-cli",
queueImmediately: false,
tags: new[] { "agent", "custom" },
CancellationToken.None);
var tags = await _tasks.GetTagsAsync(dto.Id);
Assert.Contains(tags, t => t.Name == "agent");
Assert.Contains(tags, t => t.Name == "custom");
}
[Fact]
public async Task AddTask_NullTags_BehavesAsBefore()
{
var listId = await SeedListAsync();
var queue = CreateQueue();
var sut = BuildSut(queue);
var dto = await sut.AddTask(
listId, "no tags", null, "claude-cli",
queueImmediately: false, tags: null, CancellationToken.None);
Assert.Empty(await _tasks.GetTagsAsync(dto.Id));
}
[Fact] [Fact]
public async Task UpdateTask_PatchesNonNullFieldsOnly() public async Task UpdateTask_PatchesNonNullFieldsOnly()
{ {
@@ -185,29 +131,13 @@ public sealed class ExternalMcpServiceTests : IDisposable
var queue = CreateQueue(); var queue = CreateQueue();
var sut = BuildSut(queue); var sut = BuildSut(queue);
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None); var dto = await sut.UpdateTask(task.Id, "new title", null, null, CancellationToken.None);
Assert.Equal("new title", dto.Title); Assert.Equal("new title", dto.Title);
var loaded = await _tasks.GetByIdAsync(task.Id); var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal("new title", loaded!.Title); Assert.Equal("new title", loaded!.Title);
} }
[Fact]
public async Task UpdateTask_TagsReplaceFullSet()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
var queue = CreateQueue();
var sut = BuildSut(queue);
await sut.UpdateTask(task.Id, null, null, null, new[] { "manual" }, CancellationToken.None);
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Single(tags);
Assert.Equal("manual", tags[0].Name);
}
[Fact] [Fact]
public async Task UpdateTask_OnRunning_Throws() public async Task UpdateTask_OnRunning_Throws()
{ {
@@ -217,7 +147,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var sut = BuildSut(queue); var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None)); sut.UpdateTask(task.Id, "x", null, null, CancellationToken.None));
} }
[Fact] [Fact]
@@ -227,15 +157,14 @@ public sealed class ExternalMcpServiceTests : IDisposable
var sut = BuildSut(queue); var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None)); sut.UpdateTask("does-not-exist", "x", null, null, CancellationToken.None));
} }
[Fact] [Fact]
public async Task DeleteTask_RemovesTaskAndTagJoins() public async Task DeleteTask_RemovesTask()
{ {
var listId = await SeedListAsync(); var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId); var task = await SeedTaskAsync(listId);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
var queue = CreateQueue(); var queue = CreateQueue();
var sut = BuildSut(queue); var sut = BuildSut(queue);
@@ -265,34 +194,4 @@ public sealed class ExternalMcpServiceTests : IDisposable
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.DeleteTask("does-not-exist", CancellationToken.None)); sut.DeleteTask("does-not-exist", CancellationToken.None));
} }
[Fact]
public async Task SetTaskTags_ReplacesTagSetAndBroadcasts()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
var queue = CreateQueue();
var sut = BuildSut(queue);
var dto = await sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None);
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Single(tags);
Assert.Equal("manual", tags[0].Name);
Assert.Contains(_hub.RecordingClients.Proxy.Calls,
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == task.Id);
}
[Fact]
public async Task SetTaskTags_OnRunning_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
var queue = CreateQueue();
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None));
}
} }

View File

@@ -142,8 +142,8 @@ public sealed class PlanningHubTests : IDisposable
{ {
var (_, taskId) = await SeedAsync(); var (_, taskId) = await SeedAsync();
await _planning.StartAsync(taskId, CancellationToken.None); await _planning.StartAsync(taskId, CancellationToken.None);
await _tasks.CreateChildAsync(taskId, "child 1", null, null, null); await _tasks.CreateChildAsync(taskId, "child 1", null, null);
await _tasks.CreateChildAsync(taskId, "child 2", null, null, null); await _tasks.CreateChildAsync(taskId, "child 2", null, null);
_proxy.Sent.Clear(); _proxy.Sent.Clear();
var hub = CreateHub(); var hub = CreateHub();
@@ -158,8 +158,8 @@ public sealed class PlanningHubTests : IDisposable
{ {
var (_, taskId) = await SeedAsync(); var (_, taskId) = await SeedAsync();
await _planning.StartAsync(taskId, CancellationToken.None); await _planning.StartAsync(taskId, CancellationToken.None);
await _tasks.CreateChildAsync(taskId, "c1", null, null, null); await _tasks.CreateChildAsync(taskId, "c1", null, null);
await _tasks.CreateChildAsync(taskId, "c2", null, null, null); await _tasks.CreateChildAsync(taskId, "c2", null, null);
var hub = CreateHub(); var hub = CreateHub();
var count = await hub.GetPendingDraftCountAsync(taskId); var count = await hub.GetPendingDraftCountAsync(taskId);

View File

@@ -65,7 +65,6 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
await using var ctx = _factory.CreateDbContext(); await using var ctx = _factory.CreateDbContext();
return await ctx.Tasks return await ctx.Tasks
.AsNoTracking() .AsNoTracking()
.Include(t => t.Tags)
.Where(t => t.ParentTaskId == parentId) .Where(t => t.ParentTaskId == parentId)
.OrderBy(t => t.SortOrder) .OrderBy(t => t.SortOrder)
.ToListAsync(); .ToListAsync();
@@ -88,17 +87,6 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId); Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
} }
[Fact]
public async Task SetupChain_AttachesAgentTagToAllChildren()
{
await SeedPlanningFamilyAsync("P", 2);
await _sut.SetupChainAsync("P", default);
var kids = await GetChildrenAsync("P");
Assert.All(kids, k => Assert.Contains(k.Tags, t => t.Name == "agent"));
}
[Fact] [Fact]
public async Task SetupChain_AcceptsIdleChildren() public async Task SetupChain_AcceptsIdleChildren()
{ {

View File

@@ -111,8 +111,8 @@ public sealed class PlanningEndToEndTests : IDisposable
// Wire the ambient context so _svc reads the correct parent // Wire the ambient context so _svc reads the correct parent
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id }; _httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
await _svc.CreateChildTask("sub 1", null, null, null, CancellationToken.None); await _svc.CreateChildTask("sub 1", null, null, CancellationToken.None);
await _svc.CreateChildTask("sub 2", null, null, null, CancellationToken.None); await _svc.CreateChildTask("sub 2", null, null, CancellationToken.None);
var count = await _svc.Finalize(true, CancellationToken.None); var count = await _svc.Finalize(true, CancellationToken.None);
Assert.Equal(2, count); Assert.Equal(2, count);
@@ -154,9 +154,9 @@ public sealed class PlanningEndToEndTests : IDisposable
await _manager.StartAsync(parent.Id, CancellationToken.None); await _manager.StartAsync(parent.Id, CancellationToken.None);
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id }; _httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
await _svc.CreateChildTask("c1", null, null, null, CancellationToken.None); await _svc.CreateChildTask("c1", null, null, CancellationToken.None);
await _svc.CreateChildTask("c2", null, null, null, CancellationToken.None); await _svc.CreateChildTask("c2", null, null, CancellationToken.None);
await _svc.CreateChildTask("c3", null, null, null, CancellationToken.None); await _svc.CreateChildTask("c3", null, null, CancellationToken.None);
var kidsBefore = await _tasks.GetChildrenAsync(parent.Id); var kidsBefore = await _tasks.GetChildrenAsync(parent.Id);
var firstChildId = kidsBefore[0].Id; var firstChildId = kidsBefore[0].Id;

View File

@@ -108,7 +108,7 @@ public sealed class PlanningMcpServiceTests : IDisposable
var parent = await SeedPlanningParentAsync(); var parent = await SeedPlanningParentAsync();
var sut = BuildSut(parent.Id); var sut = BuildSut(parent.Id);
var result = await sut.CreateChildTask("My child", "desc", null, null, CancellationToken.None); var result = await sut.CreateChildTask("My child", "desc", null, CancellationToken.None);
Assert.Equal("Idle", result.Status); Assert.Equal("Idle", result.Status);
var child = await _tasks.GetByIdAsync(result.TaskId); var child = await _tasks.GetByIdAsync(result.TaskId);
@@ -122,8 +122,8 @@ public sealed class PlanningMcpServiceTests : IDisposable
var parent = await SeedPlanningParentAsync(); var parent = await SeedPlanningParentAsync();
var other = await SeedPlanningParentAsync(); var other = await SeedPlanningParentAsync();
await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null); await _tasks.CreateChildAsync(parent.Id, "mine", null, null);
await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null); await _tasks.CreateChildAsync(other.Id, "theirs", null, null);
var sut = BuildSut(parent.Id); var sut = BuildSut(parent.Id);
var list = await sut.ListChildTasks(CancellationToken.None); var list = await sut.ListChildTasks(CancellationToken.None);
@@ -136,18 +136,18 @@ public sealed class PlanningMcpServiceTests : IDisposable
{ {
var parent = await SeedPlanningParentAsync(); var parent = await SeedPlanningParentAsync();
var other = await SeedPlanningParentAsync(); var other = await SeedPlanningParentAsync();
var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null, null); var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null);
var sut = BuildSut(parent.Id); var sut = BuildSut(parent.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(otherChild.Id, "new", null, null, null, null, CancellationToken.None)); sut.UpdateChildTask(otherChild.Id, "new", null, null, null, CancellationToken.None));
} }
[Fact] [Fact]
public async Task UpdateChildTask_AfterFinalize_Throws() public async Task UpdateChildTask_AfterFinalize_Throws()
{ {
var parent = await SeedPlanningParentAsync(); var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
// Simulate post-finalize state directly: parent.PlanningPhase=Finalized // Simulate post-finalize state directly: parent.PlanningPhase=Finalized
// is the gate the MCP service checks. // is the gate the MCP service checks.
var sut = BuildSut(parent.Id); var sut = BuildSut(parent.Id);
@@ -155,47 +155,18 @@ public sealed class PlanningMcpServiceTests : IDisposable
Assert.True(result.Ok, result.Reason); Assert.True(result.Ok, result.Reason);
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(c.Id, "new", null, null, null, null, CancellationToken.None)); sut.UpdateChildTask(c.Id, "new", null, null, null, CancellationToken.None));
}
[Fact]
public async Task UpdateChildTask_SetsTags()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
var result = await sut.UpdateChildTask(c.Id, null, null, new[] { "agent", "custom-tag" }, null, null, CancellationToken.None);
Assert.Contains("agent", result.Tags);
Assert.Contains("custom-tag", result.Tags);
Assert.Equal(2, result.Tags.Count);
}
[Fact]
public async Task UpdateChildTask_ReplacesTagSet()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, new[] { "agent" }, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
var result = await sut.UpdateChildTask(c.Id, null, null, new[] { "manual" }, null, null, CancellationToken.None);
Assert.Single(result.Tags);
Assert.Equal("manual", result.Tags[0]);
} }
[Fact] [Fact]
public async Task UpdateChildTask_SetsStatus() public async Task UpdateChildTask_SetsStatus()
{ {
var parent = await SeedPlanningParentAsync(); var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
_ctx.ChangeTracker.Clear(); _ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id); var sut = BuildSut(parent.Id);
var result = await sut.UpdateChildTask(c.Id, null, null, null, null, "Queued", CancellationToken.None); var result = await sut.UpdateChildTask(c.Id, null, null, null, "Queued", CancellationToken.None);
Assert.Equal("Queued", result.Status); Assert.Equal("Queued", result.Status);
var loaded = await _tasks.GetByIdAsync(c.Id); var loaded = await _tasks.GetByIdAsync(c.Id);
@@ -206,31 +177,31 @@ public sealed class PlanningMcpServiceTests : IDisposable
public async Task UpdateChildTask_DisallowedStatus_Throws() public async Task UpdateChildTask_DisallowedStatus_Throws()
{ {
var parent = await SeedPlanningParentAsync(); var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
_ctx.ChangeTracker.Clear(); _ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id); var sut = BuildSut(parent.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(c.Id, null, null, null, null, "Running", CancellationToken.None)); sut.UpdateChildTask(c.Id, null, null, null, "Running", CancellationToken.None));
} }
[Fact] [Fact]
public async Task UpdateChildTask_UnknownStatus_Throws() public async Task UpdateChildTask_UnknownStatus_Throws()
{ {
var parent = await SeedPlanningParentAsync(); var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
_ctx.ChangeTracker.Clear(); _ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id); var sut = BuildSut(parent.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(c.Id, null, null, null, null, "NotARealStatus", CancellationToken.None)); sut.UpdateChildTask(c.Id, null, null, null, "NotARealStatus", CancellationToken.None));
} }
[Fact] [Fact]
public async Task DeleteChildTask_RemovesDraft() public async Task DeleteChildTask_RemovesDraft()
{ {
var parent = await SeedPlanningParentAsync(); var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
var sut = BuildSut(parent.Id); var sut = BuildSut(parent.Id);
await sut.DeleteChildTask(c.Id, CancellationToken.None); await sut.DeleteChildTask(c.Id, CancellationToken.None);
@@ -255,8 +226,8 @@ public sealed class PlanningMcpServiceTests : IDisposable
public async Task Finalize_PromotesDraftsAndInvalidatesToken() public async Task Finalize_PromotesDraftsAndInvalidatesToken()
{ {
var parent = await SeedPlanningParentAsync(); var parent = await SeedPlanningParentAsync();
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
var sut = BuildSut(parent.Id); var sut = BuildSut(parent.Id);
var count = await sut.Finalize(true, CancellationToken.None); var count = await sut.Finalize(true, CancellationToken.None);
@@ -273,7 +244,7 @@ public sealed class PlanningMcpServiceTests : IDisposable
var parent = await SeedPlanningParentAsync(); var parent = await SeedPlanningParentAsync();
var sut = BuildSut(parent.Id); var sut = BuildSut(parent.Id);
var result = await sut.CreateChildTask("c", null, null, null, CancellationToken.None); var result = await sut.CreateChildTask("c", null, null, CancellationToken.None);
var ids = TaskUpdatedIds(); var ids = TaskUpdatedIds();
Assert.Contains(result.TaskId, ids); Assert.Contains(result.TaskId, ids);
@@ -284,11 +255,11 @@ public sealed class PlanningMcpServiceTests : IDisposable
public async Task UpdateChildTask_BroadcastsBothChildAndParent() public async Task UpdateChildTask_BroadcastsBothChildAndParent()
{ {
var parent = await SeedPlanningParentAsync(); var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
_ctx.ChangeTracker.Clear(); _ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id); var sut = BuildSut(parent.Id);
await sut.UpdateChildTask(c.Id, "new title", null, null, null, null, CancellationToken.None); await sut.UpdateChildTask(c.Id, "new title", null, null, null, CancellationToken.None);
var ids = TaskUpdatedIds(); var ids = TaskUpdatedIds();
Assert.Contains(c.Id, ids); Assert.Contains(c.Id, ids);
@@ -299,7 +270,7 @@ public sealed class PlanningMcpServiceTests : IDisposable
public async Task DeleteChildTask_BroadcastsBothChildAndParent() public async Task DeleteChildTask_BroadcastsBothChildAndParent()
{ {
var parent = await SeedPlanningParentAsync(); var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
var sut = BuildSut(parent.Id); var sut = BuildSut(parent.Id);
await sut.DeleteChildTask(c.Id, CancellationToken.None); await sut.DeleteChildTask(c.Id, CancellationToken.None);
@@ -313,8 +284,8 @@ public sealed class PlanningMcpServiceTests : IDisposable
public async Task Finalize_BroadcastsEachChildAndParent() public async Task Finalize_BroadcastsEachChildAndParent()
{ {
var parent = await SeedPlanningParentAsync(); var parent = await SeedPlanningParentAsync();
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
var sut = BuildSut(parent.Id); var sut = BuildSut(parent.Id);
await sut.Finalize(true, CancellationToken.None); await sut.Finalize(true, CancellationToken.None);

View File

@@ -131,7 +131,7 @@ public sealed class PlanningSessionManagerTests : IDisposable
var (listId, _) = await SeedListAsync(); var (listId, _) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId); var parent = await SeedManualTaskAsync(listId);
await _tasks.SetPlanningStartedAsync(parent.Id, "t"); await _tasks.SetPlanningStartedAsync(parent.Id, "t");
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.StartAsync(child.Id, CancellationToken.None)); _sut.StartAsync(child.Id, CancellationToken.None));
@@ -182,8 +182,8 @@ public sealed class PlanningSessionManagerTests : IDisposable
var (listId, _) = await SeedListAsync(); var (listId, _) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId); var parent = await SeedManualTaskAsync(listId);
await _sut.StartAsync(parent.Id, CancellationToken.None); await _sut.StartAsync(parent.Id, CancellationToken.None);
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
var count = await _sut.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None); var count = await _sut.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
@@ -200,9 +200,9 @@ public sealed class PlanningSessionManagerTests : IDisposable
var (listId, _) = await SeedListAsync(); var (listId, _) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId); var parent = await SeedManualTaskAsync(listId);
await _sut.StartAsync(parent.Id, CancellationToken.None); await _sut.StartAsync(parent.Id, CancellationToken.None);
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
await _tasks.CreateChildAsync(parent.Id, "c3", null, null, null); await _tasks.CreateChildAsync(parent.Id, "c3", null, null);
var n = await _sut.GetPendingDraftCountAsync(parent.Id, CancellationToken.None); var n = await _sut.GetPendingDraftCountAsync(parent.Id, CancellationToken.None);

View File

@@ -13,7 +13,6 @@ public sealed class QueuePickerTests : IDisposable
private readonly ClaudeDoDbContext _ctx; private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks; private readonly TaskRepository _tasks;
private readonly ListRepository _lists; private readonly ListRepository _lists;
private readonly TagRepository _tags;
private readonly QueuePicker _picker; private readonly QueuePicker _picker;
public QueuePickerTests() public QueuePickerTests()
@@ -21,7 +20,6 @@ public sealed class QueuePickerTests : IDisposable
_ctx = _db.CreateContext(); _ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx); _tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx); _lists = new ListRepository(_ctx);
_tags = new TagRepository(_ctx);
_picker = new QueuePicker(_db.CreateFactory()); _picker = new QueuePicker(_db.CreateFactory());
} }
@@ -40,11 +38,6 @@ public sealed class QueuePickerTests : IDisposable
Name = "Test", Name = "Test",
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
}); });
if (listAgentTag)
{
var tagId = await _tags.GetOrCreateAsync("agent");
await _lists.AddTagAsync(listId, tagId);
}
return listId; return listId;
} }
@@ -69,11 +62,6 @@ public sealed class QueuePickerTests : IDisposable
CommitType = "feat", CommitType = "feat",
}; };
await _tasks.AddAsync(task); await _tasks.AddAsync(task);
if (taskAgentTag)
{
var tagId = await _tags.GetOrCreateAsync("agent");
await _tasks.AddTagAsync(task.Id, tagId);
}
if (sortOrder is not null) if (sortOrder is not null)
{ {
task.SortOrder = sortOrder.Value; task.SortOrder = sortOrder.Value;

View File

@@ -10,13 +10,11 @@ public sealed class ListRepositoryTests : IDisposable
private readonly DbFixture _db = new(); private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx; private readonly ClaudeDoDbContext _ctx;
private readonly ListRepository _lists; private readonly ListRepository _lists;
private readonly TagRepository _tags;
public ListRepositoryTests() public ListRepositoryTests()
{ {
_ctx = _db.CreateContext(); _ctx = _db.CreateContext();
_lists = new ListRepository(_ctx); _lists = new ListRepository(_ctx);
_tags = new TagRepository(_ctx);
} }
public void Dispose() public void Dispose()
@@ -95,20 +93,4 @@ public sealed class ListRepositoryTests : IDisposable
Assert.True(all.Count >= 2); Assert.True(all.Count >= 2);
} }
[Fact]
public async Task TagJunction_AddAndRemove()
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "Tagged", CreatedAt = DateTime.UtcNow });
var tagId = await _tags.GetOrCreateAsync("agent");
await _lists.AddTagAsync(listId, tagId);
var tags = await _lists.GetTagsAsync(listId);
Assert.Single(tags);
Assert.Equal("agent", tags[0].Name);
await _lists.RemoveTagAsync(listId, tagId);
tags = await _lists.GetTagsAsync(listId);
Assert.Empty(tags);
}
} }

View File

@@ -68,7 +68,7 @@ public sealed class TaskRepositoryOrphanGuardTests : IDisposable
await _tasks.AddAsync(parent); await _tasks.AddAsync(parent);
var ex = await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _tasks.CreateChildAsync(parent.Id, "child", null, null, null)); () => _tasks.CreateChildAsync(parent.Id, "child", null, null));
Assert.Contains("not in a planning phase", ex.Message); Assert.Contains("not in a planning phase", ex.Message);
} }
@@ -78,7 +78,7 @@ public sealed class TaskRepositoryOrphanGuardTests : IDisposable
var listId = await CreateListAsync(); var listId = await CreateListAsync();
var parent = await SeedPlanningParentAsync(listId); var parent = await SeedPlanningParentAsync(listId);
var child = await _tasks.CreateChildAsync(parent.Id, "child", null, null, null); var child = await _tasks.CreateChildAsync(parent.Id, "child", null, null);
Assert.Equal(parent.Id, child.ParentTaskId); Assert.Equal(parent.Id, child.ParentTaskId);
Assert.Equal(TaskStatus.Idle, child.Status); Assert.Equal(TaskStatus.Idle, child.Status);
} }
@@ -101,8 +101,8 @@ public sealed class TaskRepositoryOrphanGuardTests : IDisposable
{ {
var listId = await CreateListAsync(); var listId = await CreateListAsync();
var parent = await SeedPlanningParentAsync(listId); var parent = await SeedPlanningParentAsync(listId);
await _tasks.CreateChildAsync(parent.Id, "a", null, null, null); await _tasks.CreateChildAsync(parent.Id, "a", null, null);
await _tasks.CreateChildAsync(parent.Id, "b", null, null, null); await _tasks.CreateChildAsync(parent.Id, "b", null, null);
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false); var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
@@ -117,7 +117,7 @@ public sealed class TaskRepositoryOrphanGuardTests : IDisposable
{ {
var listId = await CreateListAsync(); var listId = await CreateListAsync();
var parent = await SeedPlanningParentAsync(listId); var parent = await SeedPlanningParentAsync(listId);
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
await SetChildStatusAsync(child.Id, TaskStatus.Queued); await SetChildStatusAsync(child.Id, TaskStatus.Queued);
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false); var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
@@ -134,7 +134,7 @@ public sealed class TaskRepositoryOrphanGuardTests : IDisposable
{ {
var listId = await CreateListAsync(); var listId = await CreateListAsync();
var parent = await SeedPlanningParentAsync(listId); var parent = await SeedPlanningParentAsync(listId);
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
await SetChildStatusAsync(child.Id, TaskStatus.Queued); await SetChildStatusAsync(child.Id, TaskStatus.Queued);
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: true); var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: true);
@@ -149,7 +149,7 @@ public sealed class TaskRepositoryOrphanGuardTests : IDisposable
{ {
var listId = await CreateListAsync(); var listId = await CreateListAsync();
var parent = await SeedPlanningParentAsync(listId); var parent = await SeedPlanningParentAsync(listId);
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
await SetChildStatusAsync(child.Id, TaskStatus.Running); await SetChildStatusAsync(child.Id, TaskStatus.Running);
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: true); var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: true);
@@ -164,8 +164,8 @@ public sealed class TaskRepositoryOrphanGuardTests : IDisposable
{ {
var listId = await CreateListAsync(); var listId = await CreateListAsync();
var parent = await SeedPlanningParentAsync(listId); var parent = await SeedPlanningParentAsync(listId);
var done = await _tasks.CreateChildAsync(parent.Id, "done", null, null, null); var done = await _tasks.CreateChildAsync(parent.Id, "done", null, null);
var failed = await _tasks.CreateChildAsync(parent.Id, "failed", null, null, null); var failed = await _tasks.CreateChildAsync(parent.Id, "failed", null, null);
await SetChildStatusAsync(done.Id, TaskStatus.Done); await SetChildStatusAsync(done.Id, TaskStatus.Done);
await SetChildStatusAsync(failed.Id, TaskStatus.Failed); await SetChildStatusAsync(failed.Id, TaskStatus.Failed);
@@ -220,7 +220,7 @@ public sealed class TaskRepositoryOrphanGuardTests : IDisposable
{ {
var listId = await CreateListAsync(); var listId = await CreateListAsync();
var parent = await SeedPlanningParentAsync(listId); var parent = await SeedPlanningParentAsync(listId);
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null);
var dequeued = await _tasks.DequeueOrphanedChildrenAsync(); var dequeued = await _tasks.DequeueOrphanedChildrenAsync();
Assert.Equal(0, dequeued); Assert.Equal(0, dequeued);

View File

@@ -12,14 +12,12 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
private readonly ClaudeDoDbContext _ctx; private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks; private readonly TaskRepository _tasks;
private readonly ListRepository _lists; private readonly ListRepository _lists;
private readonly TagRepository _tags;
public TaskRepositoryPlanningTests() public TaskRepositoryPlanningTests()
{ {
_ctx = _db.CreateContext(); _ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx); _tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx); _lists = new ListRepository(_ctx);
_tags = new TagRepository(_ctx);
} }
public void Dispose() public void Dispose()
@@ -97,7 +95,6 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
parent.Id, parent.Id,
title: "child title", title: "child title",
description: "child desc", description: "child desc",
tagNames: new[] { "agent" },
commitType: "feat"); commitType: "feat");
Assert.Equal(TaskStatus.Idle, child.Status); Assert.Equal(TaskStatus.Idle, child.Status);
@@ -110,9 +107,6 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
var loaded = await _tasks.GetByIdAsync(child.Id); var loaded = await _tasks.GetByIdAsync(child.Id);
Assert.NotNull(loaded); Assert.NotNull(loaded);
Assert.Equal(TaskStatus.Idle, loaded!.Status); Assert.Equal(TaskStatus.Idle, loaded!.Status);
var tags = await _tasks.GetTagsAsync(child.Id);
Assert.Contains(tags, t => t.Name == "agent");
} }
[Fact] [Fact]
@@ -122,7 +116,7 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
_ = listId; _ = listId;
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
_tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null, null)); _tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null));
} }
[Fact] [Fact]
@@ -202,8 +196,8 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
await _tasks.AddAsync(parent); await _tasks.AddAsync(parent);
await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-42"); await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-42");
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null);
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null);
var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false); var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false);
@@ -237,7 +231,7 @@ public sealed class TaskRepositoryPlanningTests : IDisposable
var listId = await CreateListAsync(); var listId = await CreateListAsync();
var parent = MakeTask(listId, phase: PlanningPhase.Active); var parent = MakeTask(listId, phase: PlanningPhase.Active);
await _tasks.AddAsync(parent); await _tasks.AddAsync(parent);
await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); await _tasks.CreateChildAsync(parent.Id, "c", null, null);
await Assert.ThrowsAsync<Microsoft.Data.Sqlite.SqliteException>(async () => await Assert.ThrowsAsync<Microsoft.Data.Sqlite.SqliteException>(async () =>
{ {

View File

@@ -12,14 +12,12 @@ public sealed class TaskRepositoryTests : IDisposable
private readonly ClaudeDoDbContext _ctx; private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks; private readonly TaskRepository _tasks;
private readonly ListRepository _lists; private readonly ListRepository _lists;
private readonly TagRepository _tags;
public TaskRepositoryTests() public TaskRepositoryTests()
{ {
_ctx = _db.CreateContext(); _ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx); _tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx); _lists = new ListRepository(_ctx);
_tags = new TagRepository(_ctx);
} }
public void Dispose() public void Dispose()
@@ -239,83 +237,4 @@ public sealed class TaskRepositoryTests : IDisposable
Assert.Equal(0, reloadB!.SortOrder); Assert.Equal(0, reloadB!.SortOrder);
} }
[Fact]
public async Task GetEffectiveTagsAsync_Returns_Union_Of_ListTags_And_TaskTags()
{
var listId = await CreateListAsync();
var agentTagId = await _tags.GetOrCreateAsync("agent");
var manualTagId = await _tags.GetOrCreateAsync("manual");
var codeTagId = await _tags.GetOrCreateAsync("code");
await _lists.AddTagAsync(listId, agentTagId);
var task = MakeTask(listId);
await _tasks.AddAsync(task);
await _tasks.AddTagAsync(task.Id, manualTagId);
await _tasks.AddTagAsync(task.Id, codeTagId);
var effective = await _tasks.GetEffectiveTagsAsync(task.Id);
var names = effective.Select(t => t.Name).OrderBy(n => n).ToList();
Assert.Equal(3, names.Count);
Assert.Contains("agent", names);
Assert.Contains("code", names);
Assert.Contains("manual", names);
}
[Fact]
public async Task SetTagsAsync_AttachesNewTagsAndCreatesMissingRows()
{
var listId = await CreateListAsync("L");
var task = MakeTask(listId);
await _tasks.AddAsync(task);
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "novel-tag" });
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Contains(tags, t => t.Name == "agent");
Assert.Contains(tags, t => t.Name == "novel-tag");
Assert.Equal(2, tags.Count);
}
[Fact]
public async Task SetTagsAsync_ReplacesExistingTagSet()
{
var listId = await CreateListAsync("L");
var task = MakeTask(listId);
await _tasks.AddAsync(task);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
await _tasks.SetTagsAsync(task.Id, new[] { "manual" });
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Single(tags);
Assert.Equal("manual", tags[0].Name);
}
[Fact]
public async Task SetTagsAsync_DeduplicatesCaseInsensitively()
{
var listId = await CreateListAsync("L");
var task = MakeTask(listId);
await _tasks.AddAsync(task);
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "AGENT", "Agent" });
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Single(tags);
}
[Fact]
public async Task SetTagsAsync_EmptyListClearsAllTags()
{
var listId = await CreateListAsync("L");
var task = MakeTask(listId);
await _tasks.AddAsync(task);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
await _tasks.SetTagsAsync(task.Id, Array.Empty<string>());
Assert.Empty(await _tasks.GetTagsAsync(task.Id));
}
} }

View File

@@ -18,7 +18,6 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
private readonly ClaudeDoDbContext _ctx; private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _taskRepo; private readonly TaskRepository _taskRepo;
private readonly ListRepository _listRepo; private readonly ListRepository _listRepo;
private readonly TagRepository _tagRepo;
private readonly WorkerConfig _cfg; private readonly WorkerConfig _cfg;
private readonly string _tempDir; private readonly string _tempDir;
@@ -27,7 +26,6 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
_ctx = _db.CreateContext(); _ctx = _db.CreateContext();
_taskRepo = new TaskRepository(_ctx); _taskRepo = new TaskRepository(_ctx);
_listRepo = new ListRepository(_ctx); _listRepo = new ListRepository(_ctx);
_tagRepo = new TagRepository(_ctx);
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_slotguard_{Guid.NewGuid():N}"); _tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_slotguard_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir); Directory.CreateDirectory(_tempDir);
_cfg = new WorkerConfig _cfg = new WorkerConfig
@@ -68,9 +66,6 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
{ {
var listId = Guid.NewGuid().ToString(); var listId = Guid.NewGuid().ToString();
await _listRepo.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow }); 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; return listId;
} }

View File

@@ -19,7 +19,6 @@ public sealed class QueueServiceTests : IDisposable
private readonly ClaudeDoDbContext _ctx; private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _taskRepo; private readonly TaskRepository _taskRepo;
private readonly ListRepository _listRepo; private readonly ListRepository _listRepo;
private readonly TagRepository _tagRepo;
private readonly WorkerConfig _cfg; private readonly WorkerConfig _cfg;
private readonly string _tempDir; private readonly string _tempDir;
@@ -28,7 +27,6 @@ public sealed class QueueServiceTests : IDisposable
_ctx = _db.CreateContext(); _ctx = _db.CreateContext();
_taskRepo = new TaskRepository(_ctx); _taskRepo = new TaskRepository(_ctx);
_listRepo = new ListRepository(_ctx); _listRepo = new ListRepository(_ctx);
_tagRepo = new TagRepository(_ctx);
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}"); _tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir); Directory.CreateDirectory(_tempDir);
_cfg = new WorkerConfig _cfg = new WorkerConfig
@@ -69,11 +67,7 @@ public sealed class QueueServiceTests : IDisposable
{ {
var listId = Guid.NewGuid().ToString(); var listId = Guid.NewGuid().ToString();
await _listRepo.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow }); await _listRepo.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow });
return (listId, 0L);
var tags = await _tagRepo.GetAllAsync();
var agentTag = tags.First(t => t.Name == "agent");
await _listRepo.AddTagAsync(listId, agentTag.Id);
return (listId, agentTag.Id);
} }
private async Task<TaskEntity> SeedQueuedTask(string listId, DateTime? scheduledFor = null, DateTime? createdAt = null) private async Task<TaskEntity> SeedQueuedTask(string listId, DateTime? scheduledFor = null, DateTime? createdAt = null)

View File

@@ -40,8 +40,6 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null); public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask; public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
public Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames) => Task.CompletedTask;
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; } public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; }
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; } public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; }
public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;