From 3f9f0479558f6142ac29276101cfe930a6f51030 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 22 Jun 2026 17:10:51 +0200 Subject: [PATCH] feat(attachments): data layer for task file attachments TaskAttachmentEntity (+config, cascade FK), TaskAttachmentRepository, and an AttachmentStore that writes files under ~/.todo-app/attachments// with a path-traversal guard and a 5 MB cap. TaskPromptComposer gains an optional read-only 'Reference files' section. Migration AddTaskAttachments. --- src/ClaudeDo.Data/AttachmentStore.cs | 73 ++ src/ClaudeDo.Data/ClaudeDoDbContext.cs | 1 + .../TaskAttachmentEntityConfiguration.cs | 27 + ...60622150934_AddTaskAttachments.Designer.cs | 739 ++++++++++++++++++ .../20260622150934_AddTaskAttachments.cs | 48 ++ .../ClaudeDoDbContextModelSnapshot.cs | 43 + .../Models/TaskAttachmentEntity.cs | 13 + .../Repositories/TaskAttachmentRepository.cs | 45 ++ src/ClaudeDo.Data/TaskPromptComposer.cs | 11 +- .../AttachmentStoreTests.cs | 94 +++ .../TaskAttachmentRepositoryTests.cs | 116 +++ .../TaskPromptComposerTests.cs | 40 + 12 files changed, 1249 insertions(+), 1 deletion(-) create mode 100644 src/ClaudeDo.Data/AttachmentStore.cs create mode 100644 src/ClaudeDo.Data/Configuration/TaskAttachmentEntityConfiguration.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260622150934_AddTaskAttachments.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260622150934_AddTaskAttachments.cs create mode 100644 src/ClaudeDo.Data/Models/TaskAttachmentEntity.cs create mode 100644 src/ClaudeDo.Data/Repositories/TaskAttachmentRepository.cs create mode 100644 tests/ClaudeDo.Data.Tests/AttachmentStoreTests.cs create mode 100644 tests/ClaudeDo.Data.Tests/TaskAttachmentRepositoryTests.cs diff --git a/src/ClaudeDo.Data/AttachmentStore.cs b/src/ClaudeDo.Data/AttachmentStore.cs new file mode 100644 index 0000000..9219d36 --- /dev/null +++ b/src/ClaudeDo.Data/AttachmentStore.cs @@ -0,0 +1,73 @@ +namespace ClaudeDo.Data; + +public sealed class AttachmentStore +{ + private const long MaxBytes = 5 * 1024 * 1024; // 5 MB + + private readonly string _root; + + public AttachmentStore(string? root = null) + => _root = root ?? Paths.Expand("~/.todo-app/attachments"); + + public string TaskDir(string taskId) + => Path.Combine(_root, taskId); + + public async Task SaveAsync(string taskId, string fileName, Stream content, CancellationToken ct = default) + { + if (Path.GetFileName(fileName) != fileName) + throw new ArgumentException("fileName must not contain path separators or '..'.", nameof(fileName)); + + var dir = TaskDir(taskId); + var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName)); + + // Containment guard: resolved path must stay inside TaskDir + var resolvedDir = Path.GetFullPath(dir); + if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal) + && !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal)) + throw new ArgumentException("fileName resolves outside the task directory.", nameof(fileName)); + + Directory.CreateDirectory(dir); + + // Buffer up to MaxBytes + 1 to detect oversize without reading fully + await using var fs = new FileStream(resolvedPath, FileMode.Create, FileAccess.Write, FileShare.None, + bufferSize: 81920, useAsync: true); + + var buffer = new byte[81920]; + long total = 0; + int read; + while ((read = await content.ReadAsync(buffer, ct)) > 0) + { + total += read; + if (total > MaxBytes) + { + fs.Close(); + try { File.Delete(resolvedPath); } catch { } + throw new InvalidOperationException($"Attachment exceeds the 5 MB size limit."); + } + await fs.WriteAsync(buffer.AsMemory(0, read), ct); + } + + return total; + } + + public void DeleteFile(string taskId, string fileName) + { + if (Path.GetFileName(fileName) != fileName) + return; // traversal attempt — ignore silently + + var dir = TaskDir(taskId); + var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName)); + var resolvedDir = Path.GetFullPath(dir); + if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal) + && !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal)) + return; // containment violation — ignore silently + + try { File.Delete(resolvedPath); } catch (DirectoryNotFoundException) { } catch (FileNotFoundException) { } + } + + public void DeleteTaskDir(string taskId) + { + var dir = TaskDir(taskId); + try { Directory.Delete(dir, recursive: true); } catch (DirectoryNotFoundException) { } catch (IOException) { } + } +} diff --git a/src/ClaudeDo.Data/ClaudeDoDbContext.cs b/src/ClaudeDo.Data/ClaudeDoDbContext.cs index 1990afa..e6d5888 100644 --- a/src/ClaudeDo.Data/ClaudeDoDbContext.cs +++ b/src/ClaudeDo.Data/ClaudeDoDbContext.cs @@ -46,6 +46,7 @@ public class ClaudeDoDbContext : DbContext public DbSet Worktrees => Set(); public DbSet TaskRuns => Set(); public DbSet Subtasks => Set(); + public DbSet TaskAttachments => Set(); public DbSet AppSettings => Set(); public DbSet PrimeSchedules => Set(); public DbSet DailyNotes => Set(); diff --git a/src/ClaudeDo.Data/Configuration/TaskAttachmentEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskAttachmentEntityConfiguration.cs new file mode 100644 index 0000000..29afb4e --- /dev/null +++ b/src/ClaudeDo.Data/Configuration/TaskAttachmentEntityConfiguration.cs @@ -0,0 +1,27 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class TaskAttachmentEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("task_attachments"); + + builder.HasKey(a => a.Id); + builder.Property(a => a.Id).HasColumnName("id"); + builder.Property(a => a.TaskId).HasColumnName("task_id").IsRequired(); + builder.Property(a => a.FileName).HasColumnName("file_name").IsRequired(); + builder.Property(a => a.ByteSize).HasColumnName("byte_size").IsRequired(); + builder.Property(a => a.CreatedAt).HasColumnName("created_at").IsRequired(); + + builder.HasOne(a => a.Task) + .WithMany() + .HasForeignKey(a => a.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(a => a.TaskId).HasDatabaseName("idx_task_attachments_task_id"); + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260622150934_AddTaskAttachments.Designer.cs b/src/ClaudeDo.Data/Migrations/20260622150934_AddTaskAttachments.Designer.cs new file mode 100644 index 0000000..1c6b6b2 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260622150934_AddTaskAttachments.Designer.cs @@ -0,0 +1,739 @@ +// +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("20260622150934_AddTaskAttachments")] + partial class AddTaskAttachments + { + /// + 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("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("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.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("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/20260622150934_AddTaskAttachments.cs b/src/ClaudeDo.Data/Migrations/20260622150934_AddTaskAttachments.cs new file mode 100644 index 0000000..f2780df --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260622150934_AddTaskAttachments.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddTaskAttachments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "task_attachments", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + task_id = table.Column(type: "TEXT", nullable: false), + file_name = table.Column(type: "TEXT", nullable: false), + byte_size = table.Column(type: "INTEGER", nullable: false), + created_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_task_attachments", x => x.id); + table.ForeignKey( + name: "FK_task_attachments_tasks_task_id", + column: x => x.task_id, + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "idx_task_attachments_task_id", + table: "task_attachments", + column: "task_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "task_attachments"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index c0f5a11..b5286c7 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -294,6 +294,38 @@ namespace ClaudeDo.Data.Migrations 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") @@ -625,6 +657,17 @@ namespace ClaudeDo.Data.Migrations 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) diff --git a/src/ClaudeDo.Data/Models/TaskAttachmentEntity.cs b/src/ClaudeDo.Data/Models/TaskAttachmentEntity.cs new file mode 100644 index 0000000..da6ea44 --- /dev/null +++ b/src/ClaudeDo.Data/Models/TaskAttachmentEntity.cs @@ -0,0 +1,13 @@ +namespace ClaudeDo.Data.Models; + +public sealed class TaskAttachmentEntity +{ + public required string Id { get; init; } + public required string TaskId { get; init; } + public required string FileName { get; set; } + public long ByteSize { get; set; } + public required DateTime CreatedAt { get; init; } + + // Navigation property + public TaskEntity Task { get; set; } = null!; +} diff --git a/src/ClaudeDo.Data/Repositories/TaskAttachmentRepository.cs b/src/ClaudeDo.Data/Repositories/TaskAttachmentRepository.cs new file mode 100644 index 0000000..6deb761 --- /dev/null +++ b/src/ClaudeDo.Data/Repositories/TaskAttachmentRepository.cs @@ -0,0 +1,45 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class TaskAttachmentRepository +{ + private readonly ClaudeDoDbContext _context; + + public TaskAttachmentRepository(ClaudeDoDbContext context) => _context = context; + + public async Task AddAsync(TaskAttachmentEntity entity, CancellationToken ct = default) + { + _context.TaskAttachments.Add(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task> ListByTaskIdAsync(string taskId, CancellationToken ct = default) + { + return await _context.TaskAttachments + .Where(a => a.TaskId == taskId) + .OrderBy(a => a.CreatedAt) + .ToListAsync(ct); + } + + public async Task GetAsync(string taskId, string fileName, CancellationToken ct = default) + { + return await _context.TaskAttachments + .FirstOrDefaultAsync(a => a.TaskId == taskId && a.FileName == fileName, ct); + } + + public async Task DeleteAsync(string taskId, string fileName, CancellationToken ct = default) + { + await _context.TaskAttachments + .Where(a => a.TaskId == taskId && a.FileName == fileName) + .ExecuteDeleteAsync(ct); + } + + public async Task DeleteAllForTaskAsync(string taskId, CancellationToken ct = default) + { + await _context.TaskAttachments + .Where(a => a.TaskId == taskId) + .ExecuteDeleteAsync(ct); + } +} diff --git a/src/ClaudeDo.Data/TaskPromptComposer.cs b/src/ClaudeDo.Data/TaskPromptComposer.cs index 5ca3a82..0389979 100644 --- a/src/ClaudeDo.Data/TaskPromptComposer.cs +++ b/src/ClaudeDo.Data/TaskPromptComposer.cs @@ -9,7 +9,8 @@ namespace ClaudeDo.Data; /// public static class TaskPromptComposer { - public static string Compose(string title, string? description, IEnumerable<(string Title, bool Completed)> subtasks) + public static string Compose(string title, string? description, IEnumerable<(string Title, bool Completed)> subtasks, + IEnumerable? attachmentPaths = null) { var sb = new StringBuilder((title ?? "").Trim()); @@ -24,6 +25,14 @@ public static class TaskPromptComposer sb.Append("- [ ] ").Append(s.Title).Append('\n'); } + var paths = attachmentPaths?.ToList(); + if (paths is { Count: > 0 }) + { + sb.Append("\n\n## Reference files\nThese files were attached to this task as read-only reference (they live outside the repo). Read them as needed:\n"); + foreach (var p in paths) + sb.Append("- ").Append(p).Append('\n'); + } + return sb.ToString(); } } diff --git a/tests/ClaudeDo.Data.Tests/AttachmentStoreTests.cs b/tests/ClaudeDo.Data.Tests/AttachmentStoreTests.cs new file mode 100644 index 0000000..a55d80d --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/AttachmentStoreTests.cs @@ -0,0 +1,94 @@ +using ClaudeDo.Data; + +namespace ClaudeDo.Data.Tests; + +public sealed class AttachmentStoreTests : IDisposable +{ + private readonly string _root; + private readonly AttachmentStore _store; + + public AttachmentStoreTests() + { + _root = Path.Combine(Path.GetTempPath(), $"claudedo_att_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_root); + _store = new AttachmentStore(_root); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + [Fact] + public async Task Save_then_readback_size_matches() + { + var bytes = new byte[1024]; + new Random(42).NextBytes(bytes); + using var ms = new MemoryStream(bytes); + + var written = await _store.SaveAsync("task1", "data.bin", ms); + + Assert.Equal(1024, written); + var filePath = Path.Combine(_store.TaskDir("task1"), "data.bin"); + Assert.Equal(1024, new FileInfo(filePath).Length); + } + + [Fact] + public async Task Rejects_fileName_with_dotdot() + { + using var ms = new MemoryStream(new byte[10]); + await Assert.ThrowsAsync(() => + _store.SaveAsync("task1", "../escape.txt", ms)); + } + + [Fact] + public async Task Rejects_fileName_with_directory_separator() + { + using var ms = new MemoryStream(new byte[10]); + await Assert.ThrowsAsync(() => + _store.SaveAsync("task1", "sub/file.txt", ms)); + } + + [Fact] + public async Task Rejects_content_over_5MB() + { + var over = new byte[5 * 1024 * 1024 + 1]; + using var ms = new MemoryStream(over); + await Assert.ThrowsAsync(() => + _store.SaveAsync("task1", "big.bin", ms)); + } + + [Fact] + public void DeleteTaskDir_removes_directory() + { + var dir = _store.TaskDir("task2"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "a.txt"), "x"); + + _store.DeleteTaskDir("task2"); + + Assert.False(Directory.Exists(dir)); + } + + [Fact] + public void Deleting_missing_file_is_noop() + { + // Should not throw + _store.DeleteFile("taskX", "nonexistent.txt"); + } + + [Fact] + public void DeleteFile_removes_file_and_ignores_missing_second_call() + { + var dir = _store.TaskDir("task3"); + Directory.CreateDirectory(dir); + var filePath = Path.Combine(dir, "remove.txt"); + File.WriteAllText(filePath, "data"); + + _store.DeleteFile("task3", "remove.txt"); + Assert.False(File.Exists(filePath)); + + // Second call must not throw + _store.DeleteFile("task3", "remove.txt"); + } +} diff --git a/tests/ClaudeDo.Data.Tests/TaskAttachmentRepositoryTests.cs b/tests/ClaudeDo.Data.Tests/TaskAttachmentRepositoryTests.cs new file mode 100644 index 0000000..8e73fbe --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/TaskAttachmentRepositoryTests.cs @@ -0,0 +1,116 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Tests; + +public sealed class TaskAttachmentRepositoryTests : IDisposable +{ + private readonly string _dbPath; + private readonly ClaudeDoDbContext _ctx; + private readonly TaskAttachmentRepository _repo; + + private const string ListId = "l1"; + private const string TaskId = "t1"; + + public TaskAttachmentRepositoryTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_att_{Guid.NewGuid():N}.db"); + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + _ctx = new ClaudeDoDbContext(options); + _ctx.Database.EnsureCreated(); + _repo = new TaskAttachmentRepository(_ctx); + + _ctx.Lists.Add(new ListEntity { Id = ListId, Name = "Test", CreatedAt = DateTime.UtcNow }); + _ctx.Tasks.Add(new TaskEntity { Id = TaskId, ListId = ListId, Title = "T", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow }); + _ctx.SaveChanges(); + } + + public void Dispose() + { + _ctx.Dispose(); + foreach (var suffix in new[] { "", "-wal", "-shm" }) + try { File.Delete(_dbPath + suffix); } catch { } + } + + private TaskAttachmentEntity MakeAttachment(string fileName) => new() + { + Id = Guid.NewGuid().ToString(), + TaskId = TaskId, + FileName = fileName, + ByteSize = 100, + CreatedAt = DateTime.UtcNow, + }; + + [Fact] + public async Task Add_ListByTaskId_Delete_roundtrip() + { + var a1 = MakeAttachment("file1.txt"); + var a2 = MakeAttachment("file2.txt"); + + await _repo.AddAsync(a1); + await _repo.AddAsync(a2); + + var list = await _repo.ListByTaskIdAsync(TaskId); + Assert.Equal(2, list.Count); + Assert.Contains(list, a => a.FileName == "file1.txt"); + Assert.Contains(list, a => a.FileName == "file2.txt"); + + await _repo.DeleteAsync(TaskId, "file1.txt"); + + var afterDelete = await _repo.ListByTaskIdAsync(TaskId); + Assert.Single(afterDelete); + Assert.Equal("file2.txt", afterDelete[0].FileName); + } + + [Fact] + public async Task GetAsync_returns_correct_entity() + { + var a = MakeAttachment("target.txt"); + await _repo.AddAsync(a); + + var found = await _repo.GetAsync(TaskId, "target.txt"); + + Assert.NotNull(found); + Assert.Equal(a.Id, found!.Id); + } + + [Fact] + public async Task GetAsync_returns_null_when_missing() + { + var result = await _repo.GetAsync(TaskId, "nope.txt"); + Assert.Null(result); + } + + [Fact] + public async Task DeleteAllForTask_clears_all_rows_for_task() + { + await _repo.AddAsync(MakeAttachment("a.txt")); + await _repo.AddAsync(MakeAttachment("b.txt")); + await _repo.AddAsync(MakeAttachment("c.txt")); + + await _repo.DeleteAllForTaskAsync(TaskId); + + var list = await _repo.ListByTaskIdAsync(TaskId); + Assert.Empty(list); + } + + [Fact] + public async Task ListByTaskId_ordered_by_created_at() + { + var base_ = DateTime.UtcNow; + var a1 = new TaskAttachmentEntity { Id = Guid.NewGuid().ToString(), TaskId = TaskId, FileName = "first.txt", ByteSize = 1, CreatedAt = base_ }; + var a2 = new TaskAttachmentEntity { Id = Guid.NewGuid().ToString(), TaskId = TaskId, FileName = "second.txt", ByteSize = 1, CreatedAt = base_.AddSeconds(1) }; + + await _repo.AddAsync(a2); + await _repo.AddAsync(a1); + + var list = await _repo.ListByTaskIdAsync(TaskId); + Assert.Equal("first.txt", list[0].FileName); + Assert.Equal("second.txt", list[1].FileName); + } +} diff --git a/tests/ClaudeDo.Data.Tests/TaskPromptComposerTests.cs b/tests/ClaudeDo.Data.Tests/TaskPromptComposerTests.cs index 17279b3..24d57b8 100644 --- a/tests/ClaudeDo.Data.Tests/TaskPromptComposerTests.cs +++ b/tests/ClaudeDo.Data.Tests/TaskPromptComposerTests.cs @@ -42,4 +42,44 @@ public class TaskPromptComposerTests { Assert.Equal("Just a title", TaskPromptComposer.Compose("Just a title", null, System.Array.Empty<(string, bool)>())); } + + [Fact] + public void Attachment_section_present_when_paths_given() + { + var result = TaskPromptComposer.Compose( + "Title", null, System.Array.Empty<(string, bool)>(), + new[] { "/a/b/file1.txt", "/a/b/file2.txt" }); + + Assert.Contains("## Reference files", result); + Assert.Contains("- /a/b/file1.txt", result); + Assert.Contains("- /a/b/file2.txt", result); + } + + [Fact] + public void Attachment_section_absent_when_null() + { + var result = TaskPromptComposer.Compose("Title", null, System.Array.Empty<(string, bool)>(), null); + Assert.DoesNotContain("Reference files", result); + } + + [Fact] + public void Attachment_section_absent_when_empty() + { + var result = TaskPromptComposer.Compose("Title", null, System.Array.Empty<(string, bool)>(), + System.Array.Empty()); + Assert.DoesNotContain("Reference files", result); + } + + [Fact] + public void Attachment_paths_order_preserved() + { + var paths = new[] { "/z/last.txt", "/a/first.txt", "/m/middle.txt" }; + var result = TaskPromptComposer.Compose("Title", null, System.Array.Empty<(string, bool)>(), paths); + + var idxZ = result.IndexOf("/z/last.txt", StringComparison.Ordinal); + var idxA = result.IndexOf("/a/first.txt", StringComparison.Ordinal); + var idxM = result.IndexOf("/m/middle.txt", StringComparison.Ordinal); + + Assert.True(idxZ < idxA && idxA < idxM, "Paths should appear in the original order."); + } }