feat(config): Permission-Modus pro Liste und pro Task ueberschreibbar

Bisher gab es nur AppSettings.DefaultPermissionMode global — ein Task, der plan
oder acceptEdits braucht, erzwang das Umstellen der globalen Einstellung. Neue
Spalten tasks.permission_mode und list_config.permission_mode, Auflösung
task -> list -> global im EffectiveRunConfigResolver (den TaskRunner und
get_effective_run_config gemeinsam nutzen), ComboBox mit Inherited-Badge im
geteilten Agent-Editor.

MigrationBaselineTests: das Fixture baute per EnsureCreated das heutige Schema
und stempelte Legacy-History darauf — jede Migration nach dem Squash lief damit
in 'duplicate column name'. Es migriert jetzt gezielt bis InitialCreate und
prueft 'nichts pending' statt 'InitialCreate ist die einzige Zeile'.
This commit is contained in:
mika kuns
2026-08-27 10:28:57 +02:00
parent 5cc99bfec6
commit 4464dc6ff1
18 changed files with 1168 additions and 48 deletions
@@ -18,6 +18,7 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
builder.Property(c => c.MaxTurns).HasColumnName("max_turns"); builder.Property(c => c.MaxTurns).HasColumnName("max_turns");
builder.Property(c => c.SessionSkills).HasColumnName("session_skills"); builder.Property(c => c.SessionSkills).HasColumnName("session_skills");
builder.Property(c => c.VerifyCommand).HasColumnName("verify_command"); builder.Property(c => c.VerifyCommand).HasColumnName("verify_command");
builder.Property(c => c.PermissionMode).HasColumnName("permission_mode");
builder.Property(c => c.SerializeOnFileOverlap).HasColumnName("serialize_on_file_overlap") builder.Property(c => c.SerializeOnFileOverlap).HasColumnName("serialize_on_file_overlap")
.IsRequired().HasDefaultValue(false); .IsRequired().HasDefaultValue(false);
} }
@@ -93,6 +93,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.SystemPrompt).HasColumnName("system_prompt"); builder.Property(t => t.SystemPrompt).HasColumnName("system_prompt");
builder.Property(t => t.AgentPath).HasColumnName("agent_path"); builder.Property(t => t.AgentPath).HasColumnName("agent_path");
builder.Property(t => t.MaxTurns).HasColumnName("max_turns"); builder.Property(t => t.MaxTurns).HasColumnName("max_turns");
builder.Property(t => t.PermissionMode).HasColumnName("permission_mode");
builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false); builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false);
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false); builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
builder.Property(t => t.IsManual).HasColumnName("is_manual").HasDefaultValue(false); builder.Property(t => t.IsManual).HasColumnName("is_manual").HasDefaultValue(false);
@@ -0,0 +1,976 @@
// <auto-generated />
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("20260827081703_AddPermissionModeOverrides")]
partial class AddPermissionModeOverrides
{
/// <inheritdoc />
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<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<bool>("AutoContinueOnUsageLimit")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("auto_continue_on_usage_limit");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<int>("NextTaskNumber")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("next_task_number");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
AutoContinueOnUsageLimit = false,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
NextTaskNumber = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("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<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<bool>("SerializeOnFileOverlap")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("serialize_on_file_overlap");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("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<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<string>("Kind")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("ping")
.HasColumnName("action_kind");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("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<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("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<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("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<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<int?>("FailureMaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("failure_max_turns");
b.Property<string>("FailureReason")
.HasColumnType("TEXT")
.HasColumnName("failure_reason");
b.Property<int?>("FailureTurnsUsed")
.HasColumnType("INTEGER")
.HasColumnName("failure_turns_used");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<int>("Number")
.HasColumnType("INTEGER")
.HasColumnName("number");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("ScopeGlobs")
.HasColumnType("TEXT")
.HasColumnName("scope_globs");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.Property<DateTime?>("UsageLimitAutoContinuedAt")
.HasColumnType("TEXT")
.HasColumnName("usage_limit_auto_continued_at");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("DependsOnTaskId")
.HasDatabaseName("idx_tasks_depends_on");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Number")
.IsUnique()
.HasDatabaseName("idx_tasks_number");
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<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<string>("Errors")
.HasColumnType("TEXT")
.HasColumnName("errors");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<string>("ResultSubtype")
.HasColumnType("TEXT")
.HasColumnName("result_subtype");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("TerminalReason")
.HasColumnType("TEXT")
.HasColumnName("terminal_reason");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("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<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("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<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("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.TaskEntity", null)
.WithMany()
.HasForeignKey("DependsOnTaskId")
.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
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddPermissionModeOverrides : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "permission_mode",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "permission_mode",
table: "list_config",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "permission_mode",
table: "tasks");
migrationBuilder.DropColumn(
name: "permission_mode",
table: "list_config");
}
}
}
@@ -239,6 +239,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("model"); .HasColumnName("model");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<bool>("SerializeOnFileOverlap") b.Property<bool>("SerializeOnFileOverlap")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@@ -577,6 +581,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("parent_task_id"); .HasColumnName("parent_task_id");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<DateTime?>("PlanningFinalizedAt") b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("planning_finalized_at"); .HasColumnName("planning_finalized_at");
@@ -9,6 +9,8 @@ public sealed class ListConfigEntity
public int? MaxTurns { get; set; } public int? MaxTurns { get; set; }
public string? SessionSkills { get; set; } public string? SessionSkills { get; set; }
public string? VerifyCommand { get; set; } public string? VerifyCommand { get; set; }
// Null = inherit AppSettingsEntity.DefaultPermissionMode. One of PermissionModeRegistry.Modes.
public string? PermissionMode { get; set; }
// Off by default: tasks in this list run in parallel exactly as before. When on, the queue // Off by default: tasks in this list run in parallel exactly as before. When on, the queue
// picker holds back a queued task whose declared ScopeGlobs overlaps a running or // picker holds back a queued task whose declared ScopeGlobs overlaps a running or
+3
View File
@@ -62,6 +62,9 @@ public sealed class TaskEntity
public string? SystemPrompt { get; set; } public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; } public string? AgentPath { get; set; }
public int? MaxTurns { get; set; } public int? MaxTurns { get; set; }
// Null = inherit the list's config, then AppSettingsEntity.DefaultPermissionMode.
// One of PermissionModeRegistry.Modes.
public string? PermissionMode { get; set; }
public bool IsStarred { get; set; } public bool IsStarred { get; set; }
public bool IsMyDay { get; set; } public bool IsMyDay { get; set; }
// Manual = a reminder only the user can do. Automation skips it (queue picker, daily prep, // Manual = a reminder only the user can do. Automation skips it (queue picker, daily prep,
@@ -266,6 +266,7 @@ public sealed class TaskRepository
string? agentPath, string? agentPath,
int? maxTurns = null, int? maxTurns = null,
string? sessionSkills = null, string? sessionSkills = null,
string? permissionMode = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
await _context.Tasks await _context.Tasks
@@ -275,7 +276,8 @@ public sealed class TaskRepository
.SetProperty(t => t.SystemPrompt, systemPrompt) .SetProperty(t => t.SystemPrompt, systemPrompt)
.SetProperty(t => t.AgentPath, agentPath) .SetProperty(t => t.AgentPath, agentPath)
.SetProperty(t => t.MaxTurns, maxTurns) .SetProperty(t => t.MaxTurns, maxTurns)
.SetProperty(t => t.SessionSkills, sessionSkills), ct); .SetProperty(t => t.SessionSkills, sessionSkills)
.SetProperty(t => t.PermissionMode, permissionMode), ct);
} }
#endregion #endregion
+3 -3
View File
@@ -97,11 +97,11 @@ public record UpdateListDto(string Id, string Name, string? WorkingDir, string D
// SerializeOnFileOverlap is tri-state on purpose: null = leave the stored flag alone. A caller that // SerializeOnFileOverlap is tri-state on purpose: null = leave the stored flag alone. A caller that
// doesn't own the field (anything but the list-settings modal) must not be able to clear it by // doesn't own the field (anything but the list-settings modal) must not be able to clear it by
// omission — SetConfigAsync copies the entity verbatim. // omission — SetConfigAsync copies the entity verbatim.
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, bool? SerializeOnFileOverlap = null); public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, bool? SerializeOnFileOverlap = null, string? PermissionMode = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? PermissionMode = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, bool SerializeOnFileOverlap = false); public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, bool SerializeOnFileOverlap = false, string? PermissionMode = null);
public record SeedResultDto(int Copied, int Skipped); public record SeedResultDto(int Copied, int Skipped);
@@ -134,6 +134,7 @@
"agentEditor": { "agentEditor": {
"model": "Modell", "model": "Modell",
"maxTurns": "Max. Durchläufe", "maxTurns": "Max. Durchläufe",
"permissionMode": "Permission-Modus",
"systemPrompt": "System-Prompt (angehängt)", "systemPrompt": "System-Prompt (angehängt)",
"promptPrepended": "Wird automatisch vorangestellt:", "promptPrepended": "Wird automatisch vorangestellt:",
"agentFile": "Agent-Datei", "agentFile": "Agent-Datei",
@@ -134,6 +134,7 @@
"agentEditor": { "agentEditor": {
"model": "Model", "model": "Model",
"maxTurns": "Max turns", "maxTurns": "Max turns",
"permissionMode": "Permission mode",
"systemPrompt": "System prompt (appended)", "systemPrompt": "System prompt (appended)",
"promptPrepended": "Prepended automatically:", "promptPrepended": "Prepended automatically:",
"agentFile": "Agent file", "agentFile": "Agent file",
@@ -38,6 +38,8 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
[ObservableProperty] private decimal? _maxTurns; [ObservableProperty] private decimal? _maxTurns;
[ObservableProperty] private string _systemPrompt = ""; [ObservableProperty] private string _systemPrompt = "";
[ObservableProperty] private AgentInfo? _selectedAgent; [ObservableProperty] private AgentInfo? _selectedAgent;
// Null = inherit (list → global). Restricted to PermissionModeRegistry.Modes by the ComboBox.
[ObservableProperty] private string? _permissionMode;
[ObservableProperty] private string _modelBadge = ""; [ObservableProperty] private string _modelBadge = "";
[ObservableProperty] private string _modelInheritedHint = ""; [ObservableProperty] private string _modelInheritedHint = "";
@@ -45,6 +47,8 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
[ObservableProperty] private string _turnsInheritedHint = ""; [ObservableProperty] private string _turnsInheritedHint = "";
[ObservableProperty] private string _turnsCeilingHint = ""; [ObservableProperty] private string _turnsCeilingHint = "";
[ObservableProperty] private string _agentBadge = ""; [ObservableProperty] private string _agentBadge = "";
[ObservableProperty] private string _permissionBadge = "";
[ObservableProperty] private string _permissionInheritedHint = "";
[ObservableProperty] private string _effectiveSystemPromptHint = ""; [ObservableProperty] private string _effectiveSystemPromptHint = "";
private string _globalModel = ModelRegistry.DefaultAlias; private string _globalModel = ModelRegistry.DefaultAlias;
@@ -54,9 +58,11 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
private int _maxTurnsCeiling = 80; private int _maxTurnsCeiling = 80;
private string EffectiveModel => Model ?? _listModel ?? _globalModel; private string EffectiveModel => Model ?? _listModel ?? _globalModel;
private int GlobalMaxTurns => ModelPresets.For(_presets, EffectiveModel).MaxTurns; private int GlobalMaxTurns => ModelPresets.For(_presets, EffectiveModel).MaxTurns;
private string? _listModel; // Task scope only private string _globalPermissionMode = PermissionModeRegistry.DefaultMode;
private int? _listMaxTurns; // Task scope only private string? _listModel; // Task scope only
private string? _listAgentName; // Task scope only private int? _listMaxTurns; // Task scope only
private string? _listAgentName; // Task scope only
private string? _listPermissionMode; // Task scope only
private bool _suppressSave; private bool _suppressSave;
private CancellationTokenSource? _saveCts; private CancellationTokenSource? _saveCts;
@@ -65,6 +71,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? GlobalMaxTurns); MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? GlobalMaxTurns);
public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases); public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
public IReadOnlyList<string> PermissionModeOptions { get; } = PermissionModeRegistry.Modes;
public ObservableCollection<AgentInfo> Agents { get; } = new(); public ObservableCollection<AgentInfo> Agents { get; } = new();
public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new(); public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new();
@@ -121,12 +128,14 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
partial void OnSystemPromptChanged(string value) => QueueSave(); partial void OnSystemPromptChanged(string value) => QueueSave();
partial void OnSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueSave(); } partial void OnSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueSave(); }
partial void OnPermissionModeChanged(string? value) { RecomputePermissionBadge(); QueueSave(); }
private void RecomputeBadges() private void RecomputeBadges()
{ {
RecomputeModelBadge(); RecomputeModelBadge();
RecomputeTurnsBadge(); RecomputeTurnsBadge();
RecomputeAgentBadge(); RecomputeAgentBadge();
RecomputePermissionBadge();
} }
private void RecomputeModelBadge() private void RecomputeModelBadge()
@@ -162,6 +171,16 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
AgentBadge = BadgeFor(source, agentSet); AgentBadge = BadgeFor(source, agentSet);
} }
private void RecomputePermissionBadge()
{
var own = string.IsNullOrWhiteSpace(PermissionMode) ? null : PermissionMode;
var (value, source) = _scope == AgentConfigScope.Task
? InheritanceResolver.Resolve(own, _listPermissionMode, _globalPermissionMode)
: InheritanceResolver.ResolveList(own, _globalPermissionMode);
PermissionInheritedHint = value;
PermissionBadge = BadgeFor(source, own is not null);
}
private static string BadgeFor(InheritSource source, bool isSet) => isSet private static string BadgeFor(InheritSource source, bool isSet) => isSet
? Loc.T("settings.inherit.overrideBadge") ? Loc.T("settings.inherit.overrideBadge")
: source == InheritSource.List : source == InheritSource.List
@@ -202,11 +221,12 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path; var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path;
var turns = MaxTurns is decimal d ? (int?)d : null; var turns = MaxTurns is decimal d ? (int?)d : null;
var skills = SelectedSessionSkillNames(); var skills = SelectedSessionSkillNames();
var permission = string.IsNullOrWhiteSpace(PermissionMode) ? null : PermissionMode;
if (_scope == AgentConfigScope.Task) if (_scope == AgentConfigScope.Task)
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills)); await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission));
else else
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, serializeOnFileOverlap)); await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, serializeOnFileOverlap, permission));
} }
private List<string>? SelectedSessionSkillNames() private List<string>? SelectedSessionSkillNames()
@@ -225,10 +245,10 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
await LoadGlobalDefaultsAsync(); await LoadGlobalDefaultsAsync();
var cfg = await _worker.GetListConfigAsync(listId); var cfg = await _worker.GetListConfigAsync(listId);
ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath); ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath, cfg?.PermissionMode);
await ReloadSessionSkillsAsync(cfg?.SessionSkills); await ReloadSessionSkillsAsync(cfg?.SessionSkills);
_listModel = null; _listMaxTurns = null; _listAgentName = null; _listModel = null; _listMaxTurns = null; _listAgentName = null; _listPermissionMode = null;
EffectiveSystemPromptHint = ""; EffectiveSystemPromptHint = "";
RecomputeBadges(); RecomputeBadges();
OnPropertyChanged(nameof(EffectiveMaxTurns)); OnPropertyChanged(nameof(EffectiveMaxTurns));
@@ -243,13 +263,14 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
{ {
TargetId = entity.Id; TargetId = entity.Id;
await ReloadAgentsAsync("(inherited)"); await ReloadAgentsAsync("(inherited)");
ApplyConfig(entity.Model, entity.MaxTurns, entity.SystemPrompt, entity.AgentPath); ApplyConfig(entity.Model, entity.MaxTurns, entity.SystemPrompt, entity.AgentPath, entity.PermissionMode);
await ReloadSessionSkillsAsync(ParseSessionSkills(entity.SessionSkills)); await ReloadSessionSkillsAsync(ParseSessionSkills(entity.SessionSkills));
var listCfg = await _worker.GetListConfigAsync(entity.ListId); var listCfg = await _worker.GetListConfigAsync(entity.ListId);
await LoadGlobalDefaultsAsync(); await LoadGlobalDefaultsAsync();
_listModel = listCfg?.Model; _listModel = listCfg?.Model;
_listMaxTurns = listCfg?.MaxTurns; _listMaxTurns = listCfg?.MaxTurns;
_listPermissionMode = listCfg?.PermissionMode;
_listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath) _listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
? null : System.IO.Path.GetFileName(listCfg!.AgentPath!); ? null : System.IO.Path.GetFileName(listCfg!.AgentPath!);
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt) EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt)
@@ -270,6 +291,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
MaxTurns = null; MaxTurns = null;
SystemPrompt = ""; SystemPrompt = "";
SelectedAgent = null; SelectedAgent = null;
PermissionMode = null;
foreach (var s in SessionSkills) s.IsSelected = false; foreach (var s in SessionSkills) s.IsSelected = false;
} }
finally { _suppressSave = false; } finally { _suppressSave = false; }
@@ -309,13 +331,16 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
? rows.Select(r => new ModelPreset(r.Model, r.Effort, r.MaxTurns)).ToList() ? rows.Select(r => new ModelPreset(r.Model, r.Effort, r.MaxTurns)).ToList()
: ModelPresets.Defaults; : ModelPresets.Defaults;
_maxTurnsCeiling = app?.MaxTurnsCeiling ?? 80; _maxTurnsCeiling = app?.MaxTurnsCeiling ?? 80;
_globalPermissionMode = app?.DefaultPermissionMode ?? PermissionModeRegistry.DefaultMode;
} }
private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath) private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath,
string? permissionMode)
{ {
Model = string.IsNullOrWhiteSpace(model) ? null : model!; Model = string.IsNullOrWhiteSpace(model) ? null : model!;
MaxTurns = maxTurns is int mt ? mt : (decimal?)null; MaxTurns = maxTurns is int mt ? mt : (decimal?)null;
SystemPrompt = systemPrompt ?? ""; SystemPrompt = systemPrompt ?? "";
PermissionMode = string.IsNullOrWhiteSpace(permissionMode) ? null : permissionMode!;
SelectedAgent = string.IsNullOrWhiteSpace(agentPath) SelectedAgent = string.IsNullOrWhiteSpace(agentPath)
? Agents[0] ? Agents[0]
: (Agents.FirstOrDefault(a => a.Path == agentPath) ?? Agents[0]); : (Agents.FirstOrDefault(a => a.Path == agentPath) ?? Agents[0]);
@@ -324,6 +349,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
[RelayCommand] private void ResetModel() => Model = null; [RelayCommand] private void ResetModel() => Model = null;
[RelayCommand] private void ResetTurns() => MaxTurns = null; [RelayCommand] private void ResetTurns() => MaxTurns = null;
[RelayCommand] private void ResetAgent() => SelectedAgent = Agents.Count > 0 ? Agents[0] : null; [RelayCommand] private void ResetAgent() => SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
[RelayCommand] private void ResetPermissionMode() => PermissionMode = null;
[RelayCommand] [RelayCommand]
private void ResetAll() private void ResetAll()
@@ -332,6 +358,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
MaxTurns = null; MaxTurns = null;
SystemPrompt = ""; SystemPrompt = "";
SelectedAgent = Agents.Count > 0 ? Agents[0] : null; SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
PermissionMode = null;
foreach (var s in SessionSkills) s.IsSelected = false; foreach (var s in SessionSkills) s.IsSelected = false;
} }
} }
@@ -39,6 +39,20 @@
IsVisible="{Binding TurnsCeilingHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding TurnsCeilingHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
<!-- Permission mode -->
<StackPanel Spacing="4">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.agentEditor.permissionMode}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding PermissionBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetPermissionModeCommand}"/>
</Grid>
<ComboBox ItemsSource="{Binding PermissionModeOptions}"
SelectedItem="{Binding PermissionMode, Mode=TwoWay}"
PlaceholderText="{Binding PermissionInheritedHint}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<!-- System prompt --> <!-- System prompt -->
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.agentEditor.systemPrompt}"/> <TextBlock Classes="field-label" Text="{loc:Tr settings.agentEditor.systemPrompt}"/>
+9 -3
View File
@@ -81,7 +81,8 @@ public sealed class ConfigMcpTools
"in this list that don't override them (verify command is list-only; tasks cannot override it). Only " + "in this list that don't override them (verify command is list-only; tasks cannot override it). Only " +
"the fields you pass are changed; omitted fields keep their current value. To clear a field instead, " + "the fields you pass are changed; omitted fields keep their current value. To clear a field instead, " +
"name it in clearFields; clearing all five deletes the list's config unless it also carries settings " + "name it in clearFields; clearing all five deletes the list's config unless it also carries settings " +
"this tool doesn't expose (session skills, file-scope serialization), which are always preserved.")] "this tool doesn't expose (session skills, file-scope serialization, permission mode), which are " +
"always preserved.")]
public async Task<SetListConfigResult> SetListConfig( public async Task<SetListConfigResult> SetListConfig(
string listId, string? model = null, string? systemPrompt = null, string? agentPath = null, string listId, string? model = null, string? systemPrompt = null, string? agentPath = null,
int? maxTurns = null, string? verifyCommand = null, int? maxTurns = null, string? verifyCommand = null,
@@ -107,7 +108,8 @@ public sealed class ConfigMcpTools
// at its default would silently reset (a SerializeOnFileOverlap reset only shows up as // at its default would silently reset (a SerializeOnFileOverlap reset only shows up as
// tasks no longer serializing, long after this write). // tasks no longer serializing, long after this write).
var hasUnrelatedSettings = existing is not null var hasUnrelatedSettings = existing is not null
&& (existing.SessionSkills is not null || existing.SerializeOnFileOverlap); && (existing.SessionSkills is not null || existing.SerializeOnFileOverlap
|| existing.PermissionMode is not null);
ListConfigDto? config; ListConfigDto? config;
var allCleared = m is null && sp is null && ap is null && mt is null && vc is null; var allCleared = m is null && sp is null && ap is null && mt is null && vc is null;
@@ -127,6 +129,7 @@ public sealed class ConfigMcpTools
ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap, MaxTurns = mt, ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap, MaxTurns = mt,
VerifyCommand = vc, SessionSkills = existing?.SessionSkills, VerifyCommand = vc, SessionSkills = existing?.SessionSkills,
SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false, SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false,
PermissionMode = existing?.PermissionMode,
}, cancellationToken); }, cancellationToken);
config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc); config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc);
} }
@@ -158,7 +161,10 @@ public sealed class ConfigMcpTools
var ap = clear.Contains("agentPath") ? null : agentPath.NullIfBlank() ?? task.AgentPath; var ap = clear.Contains("agentPath") ? null : agentPath.NullIfBlank() ?? task.AgentPath;
var mt = clear.Contains("maxTurns") ? null : maxTurns ?? task.MaxTurns; var mt = clear.Contains("maxTurns") ? null : maxTurns ?? task.MaxTurns;
await _tasks.UpdateAgentSettingsAsync(taskId, m, sp, ap, mt, task.SessionSkills, cancellationToken); // SessionSkills and PermissionMode aren't exposed here but share the write — UpdateAgentSettings
// sets every column, so passing the current value is what keeps them from being cleared.
await _tasks.UpdateAgentSettingsAsync(
taskId, m, sp, ap, mt, task.SessionSkills, task.PermissionMode, cancellationToken);
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);
return new SetTaskConfigResult(true, taskId, task.Number, new TaskConfigDto(m, sp, ap, mt)); return new SetTaskConfigResult(true, taskId, task.Number, new TaskConfigDto(m, sp, ap, mt));
} }
+17 -3
View File
@@ -544,6 +544,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var agentPath = dto.AgentPath.NullIfBlank(); var agentPath = dto.AgentPath.NullIfBlank();
var sessionSkills = SkillsToJson(dto.SessionSkills); var sessionSkills = SkillsToJson(dto.SessionSkills);
var verifyCommand = dto.VerifyCommand.NullIfBlank(); var verifyCommand = dto.VerifyCommand.NullIfBlank();
var permissionMode = NormalizePermissionMode(dto.PermissionMode);
// A null SerializeOnFileOverlap means "leave it as stored" — only the list-settings modal // A null SerializeOnFileOverlap means "leave it as stored" — only the list-settings modal
// owns that field, so every other caller must not drop it (neither by deleting the row nor // owns that field, so every other caller must not drop it (neither by deleting the row nor
@@ -551,7 +552,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var existing = await repo.GetConfigAsync(dto.ListId); var existing = await repo.GetConfigAsync(dto.ListId);
var serializeOnFileOverlap = dto.SerializeOnFileOverlap ?? existing?.SerializeOnFileOverlap ?? false; var serializeOnFileOverlap = dto.SerializeOnFileOverlap ?? existing?.SerializeOnFileOverlap ?? false;
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && !serializeOnFileOverlap) if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && !serializeOnFileOverlap)
{ {
await repo.DeleteConfigAsync(dto.ListId); await repo.DeleteConfigAsync(dto.ListId);
} }
@@ -567,19 +568,31 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
SessionSkills = sessionSkills, SessionSkills = sessionSkills,
VerifyCommand = verifyCommand, VerifyCommand = verifyCommand,
SerializeOnFileOverlap = serializeOnFileOverlap, SerializeOnFileOverlap = serializeOnFileOverlap,
PermissionMode = permissionMode,
}); });
} }
await _broadcaster.ListUpdated(dto.ListId); await _broadcaster.ListUpdated(dto.ListId);
} }
/// Blank → null (inherit). Anything else must be a known mode: it becomes a
/// <c>--permission-mode</c> argument, and an unknown value only fails once a run starts.
private static string? NormalizePermissionMode(string? mode)
{
var value = mode.NullIfBlank();
if (value is null) return null;
if (!PermissionModeRegistry.Modes.Contains(value, StringComparer.OrdinalIgnoreCase))
throw new HubException($"unknown permission mode: {value}");
return value;
}
public async Task<ListConfigDto?> GetListConfig(string listId) public async Task<ListConfigDto?> GetListConfig(string listId)
{ {
using var ctx = _dbFactory.CreateDbContext(); using var ctx = _dbFactory.CreateDbContext();
var repo = new ListRepository(ctx); var repo = new ListRepository(ctx);
var config = await repo.GetConfigAsync(listId); var config = await repo.GetConfigAsync(listId);
if (config is null) return null; if (config is null) return null;
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.SerializeOnFileOverlap); return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.SerializeOnFileOverlap, config.PermissionMode);
} }
public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status) public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
@@ -674,7 +687,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
dto.SystemPrompt.NullIfBlank(), dto.SystemPrompt.NullIfBlank(),
dto.AgentPath.NullIfBlank(), dto.AgentPath.NullIfBlank(),
dto.MaxTurns, dto.MaxTurns,
SkillsToJson(dto.SessionSkills)); SkillsToJson(dto.SessionSkills),
NormalizePermissionMode(dto.PermissionMode));
await _broadcaster.TaskUpdated(dto.TaskId); await _broadcaster.TaskUpdated(dto.TaskId);
} }
@@ -47,7 +47,8 @@ public static class EffectiveRunConfigResolver
maxTurns, maxTurnsSource, requestedMaxTurns, maxTurns < requestedMaxTurns, maxTurns, maxTurnsSource, requestedMaxTurns, maxTurns < requestedMaxTurns,
preset.Effort, preset.Effort,
agentPath, agentPathSource, agentPath, agentPathSource,
PermissionModeResolver.Resolve(model, global.DefaultPermissionMode), PermissionModeResolver.Resolve(
model, task.PermissionMode ?? listConfig?.PermissionMode ?? global.DefaultPermissionMode),
systemPromptSources.Count > 0, systemPromptSources); systemPromptSources.Count > 0, systemPromptSources);
} }
} }
@@ -1,5 +1,7 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Tests; namespace ClaudeDo.Data.Tests;
@@ -37,40 +39,40 @@ public sealed class MigrationBaselineTests : IDisposable
using var ctx = new ClaudeDoDbContext(_options); using var ctx = new ClaudeDoDbContext(_options);
ClaudeDoDbContext.MigrateAndConfigure(ctx); ClaudeDoDbContext.MigrateAndConfigure(ctx);
Assert.Equal(new[] { SquashedMigrationId }, ctx.Database.GetAppliedMigrations()); AssertFullyMigrated(ctx);
Assert.NotEmpty(ctx.Lists); Assert.NotEmpty(ctx.Lists);
} }
[Fact] [Fact]
public void Fully_migrated_pre_squash_database_is_restamped_and_keeps_its_rows() public void Fully_migrated_pre_squash_database_is_restamped_and_keeps_its_rows()
{ {
SeedCurrentSchemaWithHistory(LastPreSquashMigrationId, taskTitle: "survivor"); SeedSquashSchemaWithHistory(LastPreSquashMigrationId, taskTitle: "survivor");
using var ctx = new ClaudeDoDbContext(_options); using var ctx = new ClaudeDoDbContext(_options);
ClaudeDoDbContext.MigrateAndConfigure(ctx); ClaudeDoDbContext.MigrateAndConfigure(ctx);
Assert.Equal(new[] { SquashedMigrationId }, ctx.Database.GetAppliedMigrations()); AssertFullyMigrated(ctx);
Assert.Equal("survivor", ctx.Tasks.Single().Title); Assert.Equal("survivor", ctx.Tasks.Single().Title);
} }
[Fact] [Fact]
public void Restamping_is_idempotent() public void Restamping_is_idempotent()
{ {
SeedCurrentSchemaWithHistory(LastPreSquashMigrationId, taskTitle: "survivor"); SeedSquashSchemaWithHistory(LastPreSquashMigrationId, taskTitle: "survivor");
using (var first = new ClaudeDoDbContext(_options)) using (var first = new ClaudeDoDbContext(_options))
ClaudeDoDbContext.MigrateAndConfigure(first); ClaudeDoDbContext.MigrateAndConfigure(first);
using var second = new ClaudeDoDbContext(_options); using var second = new ClaudeDoDbContext(_options);
ClaudeDoDbContext.MigrateAndConfigure(second); ClaudeDoDbContext.MigrateAndConfigure(second);
Assert.Equal(new[] { SquashedMigrationId }, second.Database.GetAppliedMigrations()); AssertFullyMigrated(second);
Assert.Equal("survivor", second.Tasks.Single().Title); Assert.Equal("survivor", second.Tasks.Single().Title);
} }
[Fact] [Fact]
public void Database_stuck_mid_chain_fails_loudly_instead_of_being_stamped() public void Database_stuck_mid_chain_fails_loudly_instead_of_being_stamped()
{ {
SeedCurrentSchemaWithHistory("20260810115437_AddFailureReason", taskTitle: "stale"); SeedSquashSchemaWithHistory("20260810115437_AddFailureReason", taskTitle: "stale");
using var ctx = new ClaudeDoDbContext(_options); using var ctx = new ClaudeDoDbContext(_options);
var ex = Assert.Throws<InvalidOperationException>(() => ClaudeDoDbContext.MigrateAndConfigure(ctx)); var ex = Assert.Throws<InvalidOperationException>(() => ClaudeDoDbContext.MigrateAndConfigure(ctx));
@@ -80,44 +82,50 @@ public sealed class MigrationBaselineTests : IDisposable
[Fact] [Fact]
public void Pre_ef_database_without_history_is_baselined() public void Pre_ef_database_without_history_is_baselined()
{ {
SeedCurrentSchemaWithHistory(historyId: null, taskTitle: "legacy"); SeedSquashSchemaWithHistory(historyId: null, taskTitle: "legacy");
using var ctx = new ClaudeDoDbContext(_options); using var ctx = new ClaudeDoDbContext(_options);
ClaudeDoDbContext.MigrateAndConfigure(ctx); ClaudeDoDbContext.MigrateAndConfigure(ctx);
Assert.Equal(new[] { SquashedMigrationId }, ctx.Database.GetAppliedMigrations()); AssertFullyMigrated(ctx);
Assert.Equal("legacy", ctx.Tasks.Single().Title); Assert.Equal("legacy", ctx.Tasks.Single().Title);
} }
// EnsureCreated builds today's schema without touching __EFMigrationsHistory, which is exactly // Builds the schema as of the squash point by migrating *to* InitialCreate, then rewrites the
// the shape of a database that was fully migrated by the old chain: current tables, old history. // history into the legacy shape: current tables, old (or missing) history — what a database
private void SeedCurrentSchemaWithHistory(string? historyId, string taskTitle) // fully migrated by the old chain looks like.
// NOT EnsureCreated: that builds *today's* schema, so every migration added after the squash
// would then re-add a column that already exists ("duplicate column name").
private void SeedSquashSchemaWithHistory(string? historyId, string taskTitle)
{ {
using var seed = new ClaudeDoDbContext(_options); using var seed = new ClaudeDoDbContext(_options);
seed.Database.EnsureCreated(); seed.Database.GetService<IMigrator>().Migrate(SquashedMigrationId);
var list = new ListEntity { Id = "l1", Name = "List", CreatedAt = DateTime.UtcNow };
seed.Lists.Add(list);
seed.Tasks.Add(new TaskEntity
{
Id = "t1", ListId = list.Id, Title = taskTitle, Number = 1, CreatedAt = DateTime.UtcNow,
});
seed.SaveChanges();
if (historyId is null) return;
// Raw SQL, not EF: the current model knows columns this old schema doesn't have yet.
var conn = seed.Database.GetDbConnection(); var conn = seed.Database.GetDbConnection();
conn.Open(); conn.Open();
using var cmd = conn.CreateCommand(); using var cmd = conn.CreateCommand();
cmd.CommandText = $""" cmd.CommandText = $"""
CREATE TABLE "__EFMigrationsHistory" ( INSERT INTO lists (id, name, created_at) VALUES ('l1', 'List', '2026-01-01 00:00:00');
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY, INSERT INTO tasks (id, number, list_id, title, status, created_at)
"ProductVersion" TEXT NOT NULL VALUES ('t1', 1, 'l1', '{taskTitle}', 'idle', '2026-01-01 00:00:00');
); {(historyId is null
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") ? """DROP TABLE "__EFMigrationsHistory";"""
VALUES ('{historyId}', '8.0.11'); : $"""
DELETE FROM "__EFMigrationsHistory";
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('{historyId}', '8.0.11');
""")}
"""; """;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
conn.Close(); conn.Close();
} }
/// The restamp only sets the baseline — migrations added after the squash must still apply on
/// top, so assert "nothing pending", not "InitialCreate is the only row".
private static void AssertFullyMigrated(ClaudeDoDbContext ctx)
{
Assert.Contains(SquashedMigrationId, ctx.Database.GetAppliedMigrations());
Assert.Empty(ctx.Database.GetPendingMigrations());
}
} }
@@ -135,6 +135,23 @@ public sealed class EffectiveRunConfigTests : IDisposable
Assert.Contains("task", result.SystemPrompt.Sources); Assert.Contains("task", result.SystemPrompt.Sources);
} }
[Fact]
public async Task Permission_mode_task_override_beats_list_override_beats_global()
{
var listId = await SeedListAsync();
await _lists.SetConfigAsync(new ListConfigEntity { ListId = listId, PermissionMode = "acceptEdits" });
var inherits = await SeedTaskAsync(listId);
Assert.Equal("acceptEdits", (await _sut.GetEffectiveRunConfig(inherits.Id, CancellationToken.None)).PermissionMode);
var overrides = await SeedTaskAsync(listId, t => t.PermissionMode = "plan");
Assert.Equal("plan", (await _sut.GetEffectiveRunConfig(overrides.Id, CancellationToken.None)).PermissionMode);
var otherList = await SeedListAsync();
var global = await SeedTaskAsync(otherList);
Assert.Equal("auto", (await _sut.GetEffectiveRunConfig(global.Id, CancellationToken.None)).PermissionMode);
}
[Fact] [Fact]
public async Task Unknown_task_throws() public async Task Unknown_task_throws()
{ {