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>
{
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<TaskStatus, string> 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<PlanningPhase, string> PhaseConverter =
new(v => PhaseToString(v), v => PhaseFromString(v));
public void Configure(EntityTypeBuilder<TaskEntity> builder)
{
builder.ToTable("tasks");
@@ -46,6 +79,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
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<TaskEntity>
.HasForeignKey(t => t.ParentTaskId)
.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)
.WithMany(l => l.Tasks)
.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 => 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");
}
}