feat(data): add Idle/Cancelled status, PlanningPhase enum, BlockedByTaskId field

Slice 1 of the worker-state-and-queue-consolidation refactor — additive only,
no caller changes. Introduces the new orthogonal status model:

- TaskStatus gains canonical Idle and Cancelled values; legacy values
  (Manual, Planning, Planned, Draft, Waiting) stay around until slice 6.
- New PlanningPhase enum (None/Active/Finalized) for parent tasks.
- New BlockedByTaskId FK on TaskEntity for sequential chain ordering;
  ON DELETE SET NULL so orphaned children become pickable.
- EF migration adds planning_phase and blocked_by_task_id columns plus
  the idx_tasks_blocked_by index. Also picks up an unrelated drift in
  app_settings.default_permission_mode that had been changed in code
  (commit 14cc9fb) without a migration.
This commit is contained in:
Mika Kuns
2026-04-27 10:25:53 +02:00
parent 43af17e546
commit 7b737e6717
4 changed files with 174 additions and 22 deletions

View File

@@ -9,32 +9,65 @@ namespace ClaudeDo.Data.Configuration;
public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity> public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
{ {
private static string StatusToString(TaskStatus v) private static string StatusToString(TaskStatus v)
=> v == TaskStatus.Manual ? "manual" => v switch
: v == TaskStatus.Queued ? "queued" {
: v == TaskStatus.Running ? "running" TaskStatus.Idle => "idle",
: v == TaskStatus.Done ? "done" TaskStatus.Queued => "queued",
: v == TaskStatus.Failed ? "failed" TaskStatus.Running => "running",
: v == TaskStatus.Planning ? "planning" TaskStatus.Done => "done",
: v == TaskStatus.Planned ? "planned" TaskStatus.Failed => "failed",
: v == TaskStatus.Draft ? "draft" TaskStatus.Cancelled => "cancelled",
: v == TaskStatus.Waiting ? "waiting" // Legacy values — kept for compat until slice 6 retires them.
: throw new ArgumentOutOfRangeException(nameof(v)); 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) private static TaskStatus StatusFromString(string v)
=> v == "manual" ? TaskStatus.Manual => v switch
: v == "queued" ? TaskStatus.Queued {
: v == "running" ? TaskStatus.Running "idle" => TaskStatus.Idle,
: v == "done" ? TaskStatus.Done "queued" => TaskStatus.Queued,
: v == "failed" ? TaskStatus.Failed "running" => TaskStatus.Running,
: v == "planning" ? TaskStatus.Planning "done" => TaskStatus.Done,
: v == "planned" ? TaskStatus.Planned "failed" => TaskStatus.Failed,
: v == "draft" ? TaskStatus.Draft "cancelled" => TaskStatus.Cancelled,
: v == "waiting" ? TaskStatus.Waiting // Legacy values — kept for compat until slice 6 retires them.
: throw new ArgumentOutOfRangeException(nameof(v)); "manual" => TaskStatus.Manual,
"planning" => TaskStatus.Planning,
"planned" => TaskStatus.Planned,
"draft" => TaskStatus.Draft,
"waiting" => TaskStatus.Waiting,
_ => throw new ArgumentOutOfRangeException(nameof(v)),
};
private static readonly ValueConverter<TaskStatus, string> StatusConverter = private static readonly ValueConverter<TaskStatus, string> StatusConverter =
new(v => StatusToString(v), v => StatusFromString(v)); 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<PlanningPhase, string> PhaseConverter =
new(v => PhaseToString(v), v => PhaseFromString(v));
public void Configure(EntityTypeBuilder<TaskEntity> builder) public void Configure(EntityTypeBuilder<TaskEntity> builder)
{ {
builder.ToTable("tasks"); builder.ToTable("tasks");
@@ -46,6 +79,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.Description).HasColumnName("description"); builder.Property(t => t.Description).HasColumnName("description");
builder.Property(t => t.Status).HasColumnName("status").IsRequired() builder.Property(t => t.Status).HasColumnName("status").IsRequired()
.HasConversion(StatusConverter); .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.ScheduledFor).HasColumnName("scheduled_for");
builder.Property(t => t.Result).HasColumnName("result"); builder.Property(t => t.Result).HasColumnName("result");
builder.Property(t => t.LogPath).HasColumnName("log_path"); builder.Property(t => t.LogPath).HasColumnName("log_path");
@@ -73,6 +109,12 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
.HasForeignKey(t => t.ParentTaskId) .HasForeignKey(t => t.ParentTaskId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
// BlockedBy: predecessor in a sequential chain. SetNull on delete so child becomes pickable.
builder.HasOne<TaskEntity>()
.WithMany()
.HasForeignKey(t => t.BlockedByTaskId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(t => t.List) builder.HasOne(t => t.List)
.WithMany(l => l.Tasks) .WithMany(l => l.Tasks)
.HasForeignKey(t => t.ListId) .HasForeignKey(t => t.ListId)
@@ -97,5 +139,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
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");
builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id"); builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");
} }
} }

View File

@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddPlanningPhaseAndBlockedBy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "blocked_by_task_id",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
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);
}
/// <inheritdoc />
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");
}
}
}

View File

@@ -84,7 +84,7 @@ namespace ClaudeDo.Data.Migrations
DefaultClaudeInstructions = "", DefaultClaudeInstructions = "",
DefaultMaxTurns = 100, DefaultMaxTurns = 100,
DefaultModel = "sonnet", DefaultModel = "sonnet",
DefaultPermissionMode = "bypassPermissions", DefaultPermissionMode = "auto",
WorktreeAutoCleanupDays = 7, WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false, WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling" WorktreeStrategy = "sibling"
@@ -225,6 +225,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("agent_path"); .HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType") b.Property<string>("CommitType")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -285,6 +289,13 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("planning_finalized_at"); .HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId") b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("planning_session_id"); .HasColumnName("planning_session_id");
@@ -327,6 +338,9 @@ namespace ClaudeDo.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId") b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id"); .HasDatabaseName("idx_tasks_list_id");
@@ -519,6 +533,11 @@ namespace ClaudeDo.Data.Migrations
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => 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") b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks") .WithMany("Tasks")
.HasForeignKey("ListId") .HasForeignKey("ListId")

View File

@@ -2,17 +2,31 @@ namespace ClaudeDo.Data.Models;
public enum TaskStatus public enum TaskStatus
{ {
Manual, // Lifecycle (canonical values).
Idle,
Queued, Queued,
Running, Running,
Done, Done,
Failed, 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, Planning,
Planned, Planned,
Draft, Draft,
Waiting, Waiting,
} }
public enum PlanningPhase
{
None,
Active,
Finalized,
}
public sealed class TaskEntity public sealed class TaskEntity
{ {
public required string Id { get; init; } public required string Id { get; init; }
@@ -20,6 +34,8 @@ public sealed class TaskEntity
public required string Title { get; set; } public required string Title { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public TaskStatus Status { get; set; } = TaskStatus.Manual; 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 DateTime? ScheduledFor { get; set; }
public string? Result { get; set; } public string? Result { get; set; }
public string? LogPath { get; set; } public string? LogPath { get; set; }