diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index 531b218..e503b46 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -9,32 +9,65 @@ namespace ClaudeDo.Data.Configuration; public class TaskEntityConfiguration : IEntityTypeConfiguration { private static string StatusToString(TaskStatus v) - => v == TaskStatus.Manual ? "manual" - : v == TaskStatus.Queued ? "queued" - : v == TaskStatus.Running ? "running" - : v == TaskStatus.Done ? "done" - : v == TaskStatus.Failed ? "failed" - : v == TaskStatus.Planning ? "planning" - : v == TaskStatus.Planned ? "planned" - : v == TaskStatus.Draft ? "draft" - : v == TaskStatus.Waiting ? "waiting" - : throw new ArgumentOutOfRangeException(nameof(v)); + => v switch + { + TaskStatus.Idle => "idle", + TaskStatus.Queued => "queued", + TaskStatus.Running => "running", + TaskStatus.Done => "done", + TaskStatus.Failed => "failed", + TaskStatus.Cancelled => "cancelled", + // Legacy values — kept for compat until slice 6 retires them. + TaskStatus.Manual => "manual", + TaskStatus.Planning => "planning", + TaskStatus.Planned => "planned", + TaskStatus.Draft => "draft", + TaskStatus.Waiting => "waiting", + _ => throw new ArgumentOutOfRangeException(nameof(v)), + }; private static TaskStatus StatusFromString(string v) - => v == "manual" ? TaskStatus.Manual - : v == "queued" ? TaskStatus.Queued - : v == "running" ? TaskStatus.Running - : v == "done" ? TaskStatus.Done - : v == "failed" ? TaskStatus.Failed - : v == "planning" ? TaskStatus.Planning - : v == "planned" ? TaskStatus.Planned - : v == "draft" ? TaskStatus.Draft - : v == "waiting" ? TaskStatus.Waiting - : throw new ArgumentOutOfRangeException(nameof(v)); + => v switch + { + "idle" => TaskStatus.Idle, + "queued" => TaskStatus.Queued, + "running" => TaskStatus.Running, + "done" => TaskStatus.Done, + "failed" => TaskStatus.Failed, + "cancelled" => TaskStatus.Cancelled, + // Legacy values — kept for compat until slice 6 retires them. + "manual" => TaskStatus.Manual, + "planning" => TaskStatus.Planning, + "planned" => TaskStatus.Planned, + "draft" => TaskStatus.Draft, + "waiting" => TaskStatus.Waiting, + _ => throw new ArgumentOutOfRangeException(nameof(v)), + }; private static readonly ValueConverter StatusConverter = new(v => StatusToString(v), v => StatusFromString(v)); + private static string PhaseToString(PlanningPhase v) + => v switch + { + PlanningPhase.None => "none", + PlanningPhase.Active => "active", + PlanningPhase.Finalized => "finalized", + _ => throw new ArgumentOutOfRangeException(nameof(v)), + }; + + private static PlanningPhase PhaseFromString(string v) + => v switch + { + "none" => PlanningPhase.None, + "active" => PlanningPhase.Active, + "finalized" => PlanningPhase.Finalized, + _ => throw new ArgumentOutOfRangeException(nameof(v)), + }; + + private static readonly ValueConverter PhaseConverter = + new(v => PhaseToString(v), v => PhaseFromString(v)); + public void Configure(EntityTypeBuilder builder) { builder.ToTable("tasks"); @@ -46,6 +79,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.Property(t => t.Description).HasColumnName("description"); builder.Property(t => t.Status).HasColumnName("status").IsRequired() .HasConversion(StatusConverter); + builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired() + .HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None); + builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id"); builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for"); builder.Property(t => t.Result).HasColumnName("result"); builder.Property(t => t.LogPath).HasColumnName("log_path"); @@ -73,6 +109,12 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration .HasForeignKey(t => t.ParentTaskId) .OnDelete(DeleteBehavior.Restrict); + // BlockedBy: predecessor in a sequential chain. SetNull on delete so child becomes pickable. + builder.HasOne() + .WithMany() + .HasForeignKey(t => t.BlockedByTaskId) + .OnDelete(DeleteBehavior.SetNull); + builder.HasOne(t => t.List) .WithMany(l => l.Tasks) .HasForeignKey(t => t.ListId) @@ -97,5 +139,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration 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 => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id"); + builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by"); } } diff --git a/src/ClaudeDo.Data/Migrations/20260427082248_AddPlanningPhaseAndBlockedBy.cs b/src/ClaudeDo.Data/Migrations/20260427082248_AddPlanningPhaseAndBlockedBy.cs new file mode 100644 index 0000000..301367a --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260427082248_AddPlanningPhaseAndBlockedBy.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddPlanningPhaseAndBlockedBy : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "blocked_by_task_id", + table: "tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "planning_phase", + table: "tasks", + type: "TEXT", + nullable: false, + defaultValue: "none"); + + migrationBuilder.UpdateData( + table: "app_settings", + keyColumn: "id", + keyValue: 1, + column: "default_permission_mode", + value: "auto"); + + migrationBuilder.CreateIndex( + name: "idx_tasks_blocked_by", + table: "tasks", + column: "blocked_by_task_id"); + + migrationBuilder.AddForeignKey( + name: "FK_tasks_tasks_blocked_by_task_id", + table: "tasks", + column: "blocked_by_task_id", + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_tasks_tasks_blocked_by_task_id", + table: "tasks"); + + migrationBuilder.DropIndex( + name: "idx_tasks_blocked_by", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "blocked_by_task_id", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "planning_phase", + table: "tasks"); + + migrationBuilder.UpdateData( + table: "app_settings", + keyColumn: "id", + keyValue: 1, + column: "default_permission_mode", + value: "bypassPermissions"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 2b622ae..e840ff0 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -84,7 +84,7 @@ namespace ClaudeDo.Data.Migrations DefaultClaudeInstructions = "", DefaultMaxTurns = 100, DefaultModel = "sonnet", - DefaultPermissionMode = "bypassPermissions", + DefaultPermissionMode = "auto", WorktreeAutoCleanupDays = 7, WorktreeAutoCleanupEnabled = false, WorktreeStrategy = "sibling" @@ -225,6 +225,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("agent_path"); + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + b.Property("CommitType") .IsRequired() .ValueGeneratedOnAdd() @@ -285,6 +289,13 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("planning_finalized_at"); + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + b.Property("PlanningSessionId") .HasColumnType("TEXT") .HasColumnName("planning_session_id"); @@ -327,6 +338,9 @@ namespace ClaudeDo.Data.Migrations b.HasKey("Id"); + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + b.HasIndex("ListId") .HasDatabaseName("idx_tasks_list_id"); @@ -519,6 +533,11 @@ namespace ClaudeDo.Data.Migrations modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") .WithMany("Tasks") .HasForeignKey("ListId") diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index c58eaeb..011182d 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -2,17 +2,31 @@ namespace ClaudeDo.Data.Models; public enum TaskStatus { - Manual, + // Lifecycle (canonical values). + Idle, Queued, Running, Done, Failed, + Cancelled, + + // Legacy values — kept for backwards compatibility while the worker + // layer is migrated to (Status, PlanningPhase, BlockedByTaskId). + // Removed in slice 6 of the worker-state-and-queue-consolidation refactor. + Manual, Planning, Planned, Draft, Waiting, } +public enum PlanningPhase +{ + None, + Active, + Finalized, +} + public sealed class TaskEntity { public required string Id { get; init; } @@ -20,6 +34,8 @@ public sealed class TaskEntity public required string Title { get; set; } public string? Description { get; set; } public TaskStatus Status { get; set; } = TaskStatus.Manual; + public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None; + public string? BlockedByTaskId { get; set; } public DateTime? ScheduledFor { get; set; } public string? Result { get; set; } public string? LogPath { get; set; }