From 54cdaf89d57087c70e7d02e9aecf8119a4608552 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Fri, 3 Jul 2026 09:30:33 +0200 Subject: [PATCH] feat(data): session skills entity, repository, and migration --- src/ClaudeDo.Data/ClaudeDoDbContext.cs | 1 + .../AppSettingsEntityConfiguration.cs | 2 + .../ListConfigEntityConfiguration.cs | 1 + .../SessionSkillEntityConfiguration.cs | 21 + .../Configuration/TaskEntityConfiguration.cs | 1 + ...0260703072917_AddSessionSkills.Designer.cs | 786 ++++++++++++++++++ .../20260703072917_AddSessionSkills.cs | 75 ++ .../ClaudeDoDbContextModelSnapshot.cs | 47 ++ src/ClaudeDo.Data/Models/AppSettingsEntity.cs | 3 + src/ClaudeDo.Data/Models/ListConfigEntity.cs | 1 + .../Models/SessionSkillEntity.cs | 11 + src/ClaudeDo.Data/Models/TaskEntity.cs | 1 + .../Repositories/SessionSkillRepository.cs | 61 ++ .../SessionSkillRepositoryTests.cs | 133 +++ .../SessionSkillsColumnRoundtripTests.cs | 90 ++ 15 files changed, 1234 insertions(+) create mode 100644 src/ClaudeDo.Data/Configuration/SessionSkillEntityConfiguration.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260703072917_AddSessionSkills.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260703072917_AddSessionSkills.cs create mode 100644 src/ClaudeDo.Data/Models/SessionSkillEntity.cs create mode 100644 src/ClaudeDo.Data/Repositories/SessionSkillRepository.cs create mode 100644 tests/ClaudeDo.Data.Tests/SessionSkillRepositoryTests.cs create mode 100644 tests/ClaudeDo.Data.Tests/SessionSkillsColumnRoundtripTests.cs diff --git a/src/ClaudeDo.Data/ClaudeDoDbContext.cs b/src/ClaudeDo.Data/ClaudeDoDbContext.cs index e6d58887..7000dbd2 100644 --- a/src/ClaudeDo.Data/ClaudeDoDbContext.cs +++ b/src/ClaudeDo.Data/ClaudeDoDbContext.cs @@ -51,6 +51,7 @@ public class ClaudeDoDbContext : DbContext public DbSet PrimeSchedules => Set(); public DbSet DailyNotes => Set(); public DbSet WeekReports => Set(); + public DbSet SessionSkills => Set(); private static readonly ValueConverter UtcConverter = new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); diff --git a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs index fd6a6fbe..b023ae18 100644 --- a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs @@ -44,6 +44,8 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration s.DailyPrepMaxTasks) .HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5); + builder.Property(s => s.SessionSkills).HasColumnName("session_skills"); + builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); } } diff --git a/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs index 8c887522..f4329e42 100644 --- a/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs @@ -16,5 +16,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration c.SystemPrompt).HasColumnName("system_prompt"); builder.Property(c => c.AgentPath).HasColumnName("agent_path"); builder.Property(c => c.MaxTurns).HasColumnName("max_turns"); + builder.Property(c => c.SessionSkills).HasColumnName("session_skills"); } } diff --git a/src/ClaudeDo.Data/Configuration/SessionSkillEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/SessionSkillEntityConfiguration.cs new file mode 100644 index 00000000..168d648c --- /dev/null +++ b/src/ClaudeDo.Data/Configuration/SessionSkillEntityConfiguration.cs @@ -0,0 +1,21 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class SessionSkillEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("session_skills"); + + builder.HasKey(s => s.Name); + builder.Property(s => s.Name).HasColumnName("name"); + builder.Property(s => s.SourceUrl).HasColumnName("source_url").IsRequired(); + builder.Property(s => s.PinnedRef).HasColumnName("pinned_ref").IsRequired(); + builder.Property(s => s.Subpath).HasColumnName("subpath").IsRequired(); + builder.Property(s => s.Description).HasColumnName("description").IsRequired(); + builder.Property(s => s.AddedAt).HasColumnName("added_at").IsRequired(); + } +} diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index b2ad18c8..64777e5f 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -91,6 +91,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false); builder.Property(t => t.Notes).HasColumnName("notes"); builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); + builder.Property(t => t.SessionSkills).HasColumnName("session_skills"); builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id"); builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id"); diff --git a/src/ClaudeDo.Data/Migrations/20260703072917_AddSessionSkills.Designer.cs b/src/ClaudeDo.Data/Migrations/20260703072917_AddSessionSkills.Designer.cs new file mode 100644 index 00000000..6e83e0f9 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260703072917_AddSessionSkills.Designer.cs @@ -0,0 +1,786 @@ +// +using System; +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260703072917_AddSessionSkills")] + partial class AddSessionSkills + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 100, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + StandupWeekday = 3, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PlanningFinalizedAt") + .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"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + 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") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260703072917_AddSessionSkills.cs b/src/ClaudeDo.Data/Migrations/20260703072917_AddSessionSkills.cs new file mode 100644 index 00000000..1de4db47 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260703072917_AddSessionSkills.cs @@ -0,0 +1,75 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddSessionSkills : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "session_skills", + table: "tasks", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "session_skills", + table: "list_config", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "session_skills", + table: "app_settings", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "session_skills", + columns: table => new + { + name = table.Column(type: "TEXT", nullable: false), + source_url = table.Column(type: "TEXT", nullable: false), + pinned_ref = table.Column(type: "TEXT", nullable: false), + subpath = table.Column(type: "TEXT", nullable: false), + description = table.Column(type: "TEXT", nullable: false), + added_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_session_skills", x => x.name); + }); + + migrationBuilder.UpdateData( + table: "app_settings", + keyColumn: "id", + keyValue: 1, + column: "session_skills", + value: null); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "session_skills"); + + migrationBuilder.DropColumn( + name: "session_skills", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "session_skills", + table: "list_config"); + + migrationBuilder.DropColumn( + name: "session_skills", + table: "app_settings"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index b5286c76..ea34a728 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -74,6 +74,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("report_excluded_paths"); + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + b.Property("StandupWeekday") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") @@ -168,6 +172,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("model"); + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + b.Property("SystemPrompt") .HasColumnType("TEXT") .HasColumnName("system_prompt"); @@ -256,6 +264,41 @@ namespace ClaudeDo.Data.Migrations b.ToTable("prime_schedules", (string)null); }); + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => { b.Property("Id") @@ -437,6 +480,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("scheduled_for"); + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + b.Property("SortOrder") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") diff --git a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs index 63f1d095..e3535d82 100644 --- a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs +++ b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs @@ -27,4 +27,7 @@ public sealed class AppSettingsEntity // Max number of open tasks the daily prep ("Prime Claude") may place in MyDay. public int DailyPrepMaxTasks { get; set; } = 5; + + // JSON array of session skill names applied by default to new tasks. + public string? SessionSkills { get; set; } } diff --git a/src/ClaudeDo.Data/Models/ListConfigEntity.cs b/src/ClaudeDo.Data/Models/ListConfigEntity.cs index 2051103d..cb6f538e 100644 --- a/src/ClaudeDo.Data/Models/ListConfigEntity.cs +++ b/src/ClaudeDo.Data/Models/ListConfigEntity.cs @@ -7,6 +7,7 @@ public sealed class ListConfigEntity public string? SystemPrompt { get; set; } public string? AgentPath { get; set; } public int? MaxTurns { get; set; } + public string? SessionSkills { get; set; } // Navigation property public ListEntity List { get; set; } = null!; diff --git a/src/ClaudeDo.Data/Models/SessionSkillEntity.cs b/src/ClaudeDo.Data/Models/SessionSkillEntity.cs new file mode 100644 index 00000000..368f8fa6 --- /dev/null +++ b/src/ClaudeDo.Data/Models/SessionSkillEntity.cs @@ -0,0 +1,11 @@ +namespace ClaudeDo.Data.Models; + +public sealed class SessionSkillEntity +{ + public required string Name { get; init; } + public required string SourceUrl { get; set; } + public required string PinnedRef { get; set; } + public required string Subpath { get; set; } + public required string Description { get; set; } + public required DateTimeOffset AddedAt { get; set; } +} diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index 74af7171..d288b884 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -45,6 +45,7 @@ public sealed class TaskEntity public bool IsMyDay { get; set; } public string? Notes { get; set; } public int SortOrder { get; set; } + public string? SessionSkills { get; set; } public string? ParentTaskId { get; set; } public string? PlanningSessionId { get; set; } diff --git a/src/ClaudeDo.Data/Repositories/SessionSkillRepository.cs b/src/ClaudeDo.Data/Repositories/SessionSkillRepository.cs new file mode 100644 index 00000000..7130ebe4 --- /dev/null +++ b/src/ClaudeDo.Data/Repositories/SessionSkillRepository.cs @@ -0,0 +1,61 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class SessionSkillRepository +{ + private readonly ClaudeDoDbContext _context; + + public SessionSkillRepository(ClaudeDoDbContext context) => _context = context; + + public async Task> ListAsync(CancellationToken ct = default) + { + return await _context.SessionSkills.AsNoTracking() + .OrderBy(s => s.Name) + .ToListAsync(ct); + } + + public async Task GetAsync(string name, CancellationToken ct = default) => + await _context.SessionSkills.AsNoTracking().FirstOrDefaultAsync(s => s.Name == name, ct); + + public async Task UpsertAsync(SessionSkillEntity entity, CancellationToken ct = default) + { + var existing = await _context.SessionSkills.FirstOrDefaultAsync(s => s.Name == entity.Name, ct); + if (existing is null) + { + _context.SessionSkills.Add(entity); + } + else + { + existing.SourceUrl = entity.SourceUrl; + existing.PinnedRef = entity.PinnedRef; + existing.Subpath = entity.Subpath; + existing.Description = entity.Description; + existing.AddedAt = entity.AddedAt; + } + await _context.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(string name, CancellationToken ct = default) + { + await _context.SessionSkills + .Where(s => s.Name == name) + .ExecuteDeleteAsync(ct); + } + + public async Task DeleteBySourceAsync(string sourceUrl, CancellationToken ct = default) + { + await _context.SessionSkills + .Where(s => s.SourceUrl == sourceUrl) + .ExecuteDeleteAsync(ct); + } + + public async Task> ListBySourceAsync(string sourceUrl, CancellationToken ct = default) + { + return await _context.SessionSkills.AsNoTracking() + .Where(s => s.SourceUrl == sourceUrl) + .OrderBy(s => s.Name) + .ToListAsync(ct); + } +} diff --git a/tests/ClaudeDo.Data.Tests/SessionSkillRepositoryTests.cs b/tests/ClaudeDo.Data.Tests/SessionSkillRepositoryTests.cs new file mode 100644 index 00000000..2ba4d9a0 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/SessionSkillRepositoryTests.cs @@ -0,0 +1,133 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Tests; + +public sealed class SessionSkillRepositoryTests : IDisposable +{ + private readonly string _dbPath; + private readonly ClaudeDoDbContext _ctx; + private readonly SessionSkillRepository _repo; + + public SessionSkillRepositoryTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_skills_{Guid.NewGuid():N}.db"); + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + _ctx = new ClaudeDoDbContext(options); + _ctx.Database.EnsureCreated(); + _repo = new SessionSkillRepository(_ctx); + } + + public void Dispose() + { + _ctx.Dispose(); + foreach (var suffix in new[] { "", "-wal", "-shm" }) + try { File.Delete(_dbPath + suffix); } catch { } + } + + private static SessionSkillEntity MakeSkill(string name, string sourceUrl = "https://example.com/repo") => new() + { + Name = name, + SourceUrl = sourceUrl, + PinnedRef = "main", + Subpath = "skills/" + name, + Description = "desc for " + name, + AddedAt = DateTimeOffset.UtcNow, + }; + + [Fact] + public async Task UpsertAsync_then_GetAsync_roundtrips() + { + var skill = MakeSkill("brainstorming"); + + await _repo.UpsertAsync(skill); + var found = await _repo.GetAsync("brainstorming"); + + Assert.NotNull(found); + Assert.Equal(skill.SourceUrl, found!.SourceUrl); + Assert.Equal(skill.PinnedRef, found.PinnedRef); + Assert.Equal(skill.Subpath, found.Subpath); + Assert.Equal(skill.Description, found.Description); + } + + [Fact] + public async Task UpsertAsync_updates_existing_row() + { + await _repo.UpsertAsync(MakeSkill("brainstorming", "https://example.com/old")); + await _repo.UpsertAsync(MakeSkill("brainstorming", "https://example.com/new")); + + var found = await _repo.GetAsync("brainstorming"); + + Assert.NotNull(found); + Assert.Equal("https://example.com/new", found!.SourceUrl); + + var all = await _repo.ListAsync(); + Assert.Single(all); + } + + [Fact] + public async Task GetAsync_returns_null_when_missing() + { + var found = await _repo.GetAsync("nope"); + Assert.Null(found); + } + + [Fact] + public async Task ListAsync_returns_all_ordered_by_name() + { + await _repo.UpsertAsync(MakeSkill("zeta")); + await _repo.UpsertAsync(MakeSkill("alpha")); + + var all = await _repo.ListAsync(); + + Assert.Equal(2, all.Count); + Assert.Equal("alpha", all[0].Name); + Assert.Equal("zeta", all[1].Name); + } + + [Fact] + public async Task DeleteAsync_removes_only_matching_row() + { + await _repo.UpsertAsync(MakeSkill("keep")); + await _repo.UpsertAsync(MakeSkill("remove")); + + await _repo.DeleteAsync("remove"); + + var all = await _repo.ListAsync(); + Assert.Single(all); + Assert.Equal("keep", all[0].Name); + } + + [Fact] + public async Task DeleteBySourceAsync_removes_multiple_rows_same_source() + { + const string source = "https://example.com/shared-repo"; + await _repo.UpsertAsync(MakeSkill("skill-a", source)); + await _repo.UpsertAsync(MakeSkill("skill-b", source)); + await _repo.UpsertAsync(MakeSkill("skill-c", "https://example.com/other")); + + await _repo.DeleteBySourceAsync(source); + + var all = await _repo.ListAsync(); + Assert.Single(all); + Assert.Equal("skill-c", all[0].Name); + } + + [Fact] + public async Task ListBySourceAsync_returns_only_matching_rows() + { + const string source = "https://example.com/shared-repo"; + await _repo.UpsertAsync(MakeSkill("skill-a", source)); + await _repo.UpsertAsync(MakeSkill("skill-b", source)); + await _repo.UpsertAsync(MakeSkill("skill-c", "https://example.com/other")); + + var result = await _repo.ListBySourceAsync(source); + + Assert.Equal(2, result.Count); + Assert.All(result, s => Assert.Equal(source, s.SourceUrl)); + } +} diff --git a/tests/ClaudeDo.Data.Tests/SessionSkillsColumnRoundtripTests.cs b/tests/ClaudeDo.Data.Tests/SessionSkillsColumnRoundtripTests.cs new file mode 100644 index 00000000..27546e67 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/SessionSkillsColumnRoundtripTests.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Tests; + +public sealed class SessionSkillsColumnRoundtripTests : IDisposable +{ + private readonly string _dbPath; + private readonly ClaudeDoDbContext _ctx; + + public SessionSkillsColumnRoundtripTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_skillscol_{Guid.NewGuid():N}.db"); + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + _ctx = new ClaudeDoDbContext(options); + _ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + _ctx.Dispose(); + foreach (var suffix in new[] { "", "-wal", "-shm" }) + try { File.Delete(_dbPath + suffix); } catch { } + } + + [Fact] + public async Task Task_SessionSkills_json_array_roundtrips() + { + var names = new List { "a", "b" }; + var list = new ListEntity { Id = "l1", Name = "Test", CreatedAt = DateTime.UtcNow }; + var task = new TaskEntity + { + Id = "t1", + ListId = "l1", + Title = "T", + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + SessionSkills = JsonSerializer.Serialize(names), + }; + _ctx.Lists.Add(list); + _ctx.Tasks.Add(task); + await _ctx.SaveChangesAsync(); + + await using var freshCtx = new ClaudeDoDbContext( + new DbContextOptionsBuilder().UseSqlite($"Data Source={_dbPath}").Options); + var reloaded = await freshCtx.Tasks.AsNoTracking().FirstAsync(t => t.Id == "t1"); + + var roundtripped = JsonSerializer.Deserialize>(reloaded.SessionSkills!); + Assert.Equal(names, roundtripped); + } + + [Fact] + public async Task ListConfig_SessionSkills_json_array_roundtrips() + { + var names = new List { "a", "b" }; + var list = new ListEntity { Id = "l1", Name = "Test", CreatedAt = DateTime.UtcNow }; + var config = new ListConfigEntity { ListId = "l1", SessionSkills = JsonSerializer.Serialize(names) }; + _ctx.Lists.Add(list); + _ctx.ListConfigs.Add(config); + await _ctx.SaveChangesAsync(); + + await using var freshCtx = new ClaudeDoDbContext( + new DbContextOptionsBuilder().UseSqlite($"Data Source={_dbPath}").Options); + var reloaded = await freshCtx.ListConfigs.AsNoTracking().FirstAsync(c => c.ListId == "l1"); + + var roundtripped = JsonSerializer.Deserialize>(reloaded.SessionSkills!); + Assert.Equal(names, roundtripped); + } + + [Fact] + public async Task AppSettings_SessionSkills_json_array_roundtrips() + { + var names = new List { "a", "b" }; + var settings = await _ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId); + settings.SessionSkills = JsonSerializer.Serialize(names); + await _ctx.SaveChangesAsync(); + + await using var freshCtx = new ClaudeDoDbContext( + new DbContextOptionsBuilder().UseSqlite($"Data Source={_dbPath}").Options); + var reloaded = await freshCtx.AppSettings.AsNoTracking().FirstAsync(s => s.Id == AppSettingsEntity.SingletonId); + + var roundtripped = JsonSerializer.Deserialize>(reloaded.SessionSkills!); + Assert.Equal(names, roundtripped); + } +}