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
@@ -1,5 +1,7 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Tests;
@@ -37,40 +39,40 @@ public sealed class MigrationBaselineTests : IDisposable
using var ctx = new ClaudeDoDbContext(_options);
ClaudeDoDbContext.MigrateAndConfigure(ctx);
Assert.Equal(new[] { SquashedMigrationId }, ctx.Database.GetAppliedMigrations());
AssertFullyMigrated(ctx);
Assert.NotEmpty(ctx.Lists);
}
[Fact]
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);
ClaudeDoDbContext.MigrateAndConfigure(ctx);
Assert.Equal(new[] { SquashedMigrationId }, ctx.Database.GetAppliedMigrations());
AssertFullyMigrated(ctx);
Assert.Equal("survivor", ctx.Tasks.Single().Title);
}
[Fact]
public void Restamping_is_idempotent()
{
SeedCurrentSchemaWithHistory(LastPreSquashMigrationId, taskTitle: "survivor");
SeedSquashSchemaWithHistory(LastPreSquashMigrationId, taskTitle: "survivor");
using (var first = new ClaudeDoDbContext(_options))
ClaudeDoDbContext.MigrateAndConfigure(first);
using var second = new ClaudeDoDbContext(_options);
ClaudeDoDbContext.MigrateAndConfigure(second);
Assert.Equal(new[] { SquashedMigrationId }, second.Database.GetAppliedMigrations());
AssertFullyMigrated(second);
Assert.Equal("survivor", second.Tasks.Single().Title);
}
[Fact]
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);
var ex = Assert.Throws<InvalidOperationException>(() => ClaudeDoDbContext.MigrateAndConfigure(ctx));
@@ -80,44 +82,50 @@ public sealed class MigrationBaselineTests : IDisposable
[Fact]
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);
ClaudeDoDbContext.MigrateAndConfigure(ctx);
Assert.Equal(new[] { SquashedMigrationId }, ctx.Database.GetAppliedMigrations());
AssertFullyMigrated(ctx);
Assert.Equal("legacy", ctx.Tasks.Single().Title);
}
// EnsureCreated builds today's schema without touching __EFMigrationsHistory, which is exactly
// the shape of a database that was fully migrated by the old chain: current tables, old history.
private void SeedCurrentSchemaWithHistory(string? historyId, string taskTitle)
// Builds the schema as of the squash point by migrating *to* InitialCreate, then rewrites the
// history into the legacy shape: current tables, old (or missing) history — what a database
// 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);
seed.Database.EnsureCreated();
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;
seed.Database.GetService<IMigrator>().Migrate(SquashedMigrationId);
// Raw SQL, not EF: the current model knows columns this old schema doesn't have yet.
var conn = seed.Database.GetDbConnection();
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"""
CREATE TABLE "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('{historyId}', '8.0.11');
INSERT INTO lists (id, name, created_at) VALUES ('l1', 'List', '2026-01-01 00:00:00');
INSERT INTO tasks (id, number, list_id, title, status, created_at)
VALUES ('t1', 1, 'l1', '{taskTitle}', 'idle', '2026-01-01 00:00:00');
{(historyId is null
? """DROP TABLE "__EFMigrationsHistory";"""
: $"""
DELETE FROM "__EFMigrationsHistory";
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('{historyId}', '8.0.11');
""")}
""";
cmd.ExecuteNonQuery();
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);
}
[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]
public async Task Unknown_task_throws()
{