diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 7716b7a6..c2678b47 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -18,6 +18,7 @@ using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Refine; using ClaudeDo.Worker.Report; using ClaudeDo.Worker.Report.Interfaces; +using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.Worktrees; using Microsoft.EntityFrameworkCore; using Serilog; @@ -73,6 +74,9 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs b/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs index 162891a3..137fde3a 100644 --- a/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs +++ b/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs @@ -10,8 +10,12 @@ public sealed record ClaudeRunConfig( int? MaxTurns = null, string? PermissionMode = null, string? McpConfigPath = null, - string? AllowedTools = null -); + string? AllowedTools = null, + IReadOnlyList? SkillNames = null +) +{ + public IReadOnlyList SkillNames { get; init; } = SkillNames ?? Array.Empty(); +} public sealed class ClaudeArgsBuilder { diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index f86a20e9..3d362bac 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -4,6 +4,7 @@ using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.State; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -22,6 +23,7 @@ public sealed class TaskRunner private readonly ITaskStateService _state; private readonly TaskRunTokenRegistry _tokens; private readonly AttachmentStore _attachments; + private readonly ISessionSkillSeeder _skillSeeder; public TaskRunner( IClaudeProcess claude, @@ -33,7 +35,8 @@ public sealed class TaskRunner ILogger logger, ITaskStateService state, TaskRunTokenRegistry tokens, - AttachmentStore attachments) + AttachmentStore attachments, + ISessionSkillSeeder skillSeeder) { _claude = claude; _dbFactory = dbFactory; @@ -45,6 +48,7 @@ public sealed class TaskRunner _state = state; _tokens = tokens; _attachments = attachments; + _skillSeeder = skillSeeder; } public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct, bool alreadyClaimed = false) @@ -122,6 +126,8 @@ public sealed class TaskRunner } await _broadcaster.TaskStarted(slot, task.Id, now); + await _skillSeeder.SeedAsync(runDir, resolvedConfig.SkillNames, wtCtx is not null, ct); + // Build prompt: title + description + only the OPEN sub-tasks (resolved ones are dropped). var prompt = TaskPromptComposer.Compose( task.Title, task.Description, @@ -242,6 +248,8 @@ public sealed class TaskRunner } await _broadcaster.TaskStarted(slot, taskId, now); + await _skillSeeder.SeedAsync(runDir, resolvedConfig.SkillNames, wtCtx is not null, ct); + try { var nextRunNumber = lastRun.RunNumber + 1; @@ -505,13 +513,70 @@ public sealed class TaskRunner var instructions = MergeInstructions( systemFile, improvementPrompt, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt); + var requestedSkills = UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills); + var skillNames = await FilterToInstalledSkillsAsync(task.Id, requestedSkills, ct); + return new ClaudeRunConfig( Model: task.Model ?? listConfig?.Model ?? global.DefaultModel, SystemPrompt: string.IsNullOrWhiteSpace(instructions) ? null : instructions, AgentPath: task.AgentPath ?? listConfig?.AgentPath, ResumeSessionId: resumeSessionId, MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, global.DefaultMaxTurns), - PermissionMode: global.DefaultPermissionMode); + PermissionMode: global.DefaultPermissionMode, + SkillNames: skillNames); + } + + private async Task> FilterToInstalledSkillsAsync( + string taskId, IReadOnlyList requestedSkills, CancellationToken ct) + { + if (requestedSkills.Count == 0) return requestedSkills; + + List installed; + using (var ctx = _dbFactory.CreateDbContext()) + { + var skillRepo = new SessionSkillRepository(ctx); + installed = (await skillRepo.ListAsync(ct)).ToList(); + } + + var installedNames = installed.Select(s => s.Name).ToHashSet(StringComparer.Ordinal); + var resolved = requestedSkills.Where(installedNames.Contains).ToList(); + var dropped = requestedSkills.Where(n => !installedNames.Contains(n)).ToList(); + if (dropped.Count > 0) + { + _logger.LogWarning( + "Task {TaskId}: dropped unknown session skill(s) not found in registry: {SkillNames}", + taskId, string.Join(", ", dropped)); + } + + return resolved; + } + + internal static IReadOnlyList UnionSkillNames(params string?[] jsonArrays) + { + var names = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var json in jsonArrays) + { + if (string.IsNullOrWhiteSpace(json)) continue; + + string[]? parsed; + try + { + parsed = JsonSerializer.Deserialize(json); + } + catch (JsonException) + { + continue; + } + if (parsed is null) continue; + + foreach (var name in parsed) + { + if (!string.IsNullOrWhiteSpace(name) && seen.Add(name)) + names.Add(name); + } + } + return names; } internal static int? ResolveMaxTurns(int? taskTurns, int? listTurns, int globalDefault) diff --git a/src/ClaudeDo.Worker/Skills/Interfaces/ISessionSkillSeeder.cs b/src/ClaudeDo.Worker/Skills/Interfaces/ISessionSkillSeeder.cs new file mode 100644 index 00000000..304a9072 --- /dev/null +++ b/src/ClaudeDo.Worker/Skills/Interfaces/ISessionSkillSeeder.cs @@ -0,0 +1,6 @@ +namespace ClaudeDo.Worker.Skills; + +public interface ISessionSkillSeeder +{ + Task SeedAsync(string workingDir, IReadOnlyList skillNames, bool isWorktree, CancellationToken ct); +} diff --git a/src/ClaudeDo.Worker/Skills/SessionSkillSeeder.cs b/src/ClaudeDo.Worker/Skills/SessionSkillSeeder.cs new file mode 100644 index 00000000..c3597385 --- /dev/null +++ b/src/ClaudeDo.Worker/Skills/SessionSkillSeeder.cs @@ -0,0 +1,116 @@ +using System.Diagnostics; +using System.Text; +using ClaudeDo.Data; +using Microsoft.Extensions.Logging; + +namespace ClaudeDo.Worker.Skills; + +public sealed class SessionSkillSeeder : ISessionSkillSeeder +{ + private readonly string _skillsRoot; + private readonly ILogger _logger; + + public SessionSkillSeeder(ILogger logger, string? skillsRoot = null) + { + _logger = logger; + _skillsRoot = skillsRoot ?? Path.Combine(Paths.AppDataRoot(), "session-skills"); + } + + public async Task SeedAsync(string workingDir, IReadOnlyList skillNames, bool isWorktree, CancellationToken ct) + { + if (skillNames.Count == 0) return; + + var skillsDestRoot = Path.Combine(workingDir, ".claude", "skills"); + + foreach (var name in skillNames) + { + var sourceDir = Path.Combine(_skillsRoot, name); + if (!Directory.Exists(sourceDir)) + { + _logger.LogWarning("Session skill '{SkillName}' not found on disk at {SourceDir}; skipping", name, sourceDir); + continue; + } + + var destDir = Path.Combine(skillsDestRoot, name); + CopyDirectory(sourceDir, destDir); + + if (isWorktree) + await AppendExcludeLineAsync(workingDir, $"/.claude/skills/{name}/", ct); + } + } + + private static void CopyDirectory(string sourceDir, string destDir) + { + Directory.CreateDirectory(destDir); + foreach (var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(sourceDir, file); + var destPath = Path.Combine(destDir, relative); + var destParent = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destParent)) + Directory.CreateDirectory(destParent); + File.Copy(file, destPath, overwrite: true); + } + } + + private static async Task AppendExcludeLineAsync(string workingDir, string excludeLine, CancellationToken ct) + { + var relativeExcludePath = await RunGitCaptureAsync(workingDir, ["rev-parse", "--git-path", "info/exclude"], ct); + var excludeFile = Path.IsPathRooted(relativeExcludePath) + ? relativeExcludePath + : Path.GetFullPath(Path.Combine(workingDir, relativeExcludePath)); + + var excludeDir = Path.GetDirectoryName(excludeFile); + if (!string.IsNullOrEmpty(excludeDir)) + Directory.CreateDirectory(excludeDir); + + if (File.Exists(excludeFile)) + { + var existingLines = await File.ReadAllLinesAsync(excludeFile, ct); + if (existingLines.Any(l => l.Trim() == excludeLine)) + return; + } + + await File.AppendAllTextAsync(excludeFile, excludeLine + Environment.NewLine, ct); + } + + private static async Task RunGitCaptureAsync(string workingDir, IEnumerable args, CancellationToken ct) + { + var psi = new ProcessStartInfo + { + FileName = "git", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + psi.ArgumentList.Add("-C"); + psi.ArgumentList.Add(workingDir); + foreach (var a in args) psi.ArgumentList.Add(a); + + using var proc = new Process { StartInfo = psi }; + proc.Start(); + + await using var ctr = ct.Register(() => + { + try { proc.Kill(entireProcessTree: true); } + catch { /* already exited */ } + }); + + var stdoutTask = proc.StandardOutput.ReadToEndAsync(); + var stderrTask = proc.StandardError.ReadToEndAsync(); + + await proc.WaitForExitAsync(CancellationToken.None); + var stdout = await stdoutTask; + var stderr = await stderrTask; + + ct.ThrowIfCancellationRequested(); + + if (proc.ExitCode != 0) + throw new InvalidOperationException($"git rev-parse --git-path failed (exit {proc.ExitCode}): {stderr.TrimEnd()}"); + + return stdout.TrimEnd('\r', '\n'); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs index af763dd9..d5ac186d 100644 --- a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs @@ -69,7 +69,7 @@ public sealed class AddSubtaskToolTests : IDisposable var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); var waker = new ClaudeDo.Worker.Queue.QueueWaker(); var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs index 6126760a..f38eb8bc 100644 --- a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -87,7 +87,7 @@ public sealed class BatchMcpToolsTests : IDisposable var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, new ClaudeArgsBuilder(), cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance); return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 31fc768e..adb8e964 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -149,7 +149,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); var waker = new ClaudeDo.Worker.Queue.QueueWaker(); var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/FakeSessionSkillSeeder.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/FakeSessionSkillSeeder.cs new file mode 100644 index 00000000..c4a2d352 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/FakeSessionSkillSeeder.cs @@ -0,0 +1,18 @@ +using System.Collections.Concurrent; +using ClaudeDo.Worker.Skills; + +namespace ClaudeDo.Worker.Tests.Infrastructure; + +public sealed class FakeSessionSkillSeeder : ISessionSkillSeeder +{ + public int CallCount; + public readonly record struct Call(string WorkingDir, IReadOnlyList SkillNames, bool IsWorktree); + public readonly ConcurrentQueue Calls = new(); + + public Task SeedAsync(string workingDir, IReadOnlyList skillNames, bool isWorktree, CancellationToken ct) + { + Interlocked.Increment(ref CallCount); + Calls.Enqueue(new Call(workingDir, skillNames, isWorktree)); + return Task.CompletedTask; + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs index 4ecdd5bd..223e4f17 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs @@ -36,7 +36,7 @@ public sealed class ContinueAsyncExceptionTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger.Instance); return new TaskRunner(claude, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/Runner/SkillResolutionTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/SkillResolutionTests.cs new file mode 100644 index 00000000..570b45c1 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Runner/SkillResolutionTests.cs @@ -0,0 +1,44 @@ +using ClaudeDo.Worker.Runner; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Runner; + +public class SkillResolutionTests +{ + [Fact] + public void Null_and_empty_sources_contribute_nothing() + => Assert.Empty(TaskRunner.UnionSkillNames(null, "", " ", "[]")); + + [Fact] + public void Single_source_is_returned_in_order() + => Assert.Equal(["a", "b"], TaskRunner.UnionSkillNames("""["a","b"]""")); + + [Fact] + public void Unions_across_task_list_global_levels() + { + var result = TaskRunner.UnionSkillNames("""["a"]""", """["b"]""", """["c"]"""); + Assert.Equal(["a", "b", "c"], result); + } + + [Fact] + public void Dedups_ordinal_preserving_first_occurrence_order() + { + var result = TaskRunner.UnionSkillNames("""["a","b"]""", """["b","c"]""", """["a","d"]"""); + Assert.Equal(["a", "b", "c", "d"], result); + } + + [Fact] + public void Dedup_is_case_sensitive_ordinal() + { + var result = TaskRunner.UnionSkillNames("""["Foo"]""", """["foo"]"""); + Assert.Equal(["Foo", "foo"], result); + } + + [Fact] + public void Malformed_json_is_ignored_not_thrown() + => Assert.Equal(["a"], TaskRunner.UnionSkillNames("not json", """["a"]""")); + + [Fact] + public void Blank_entries_within_array_are_skipped() + => Assert.Equal(["a"], TaskRunner.UnionSkillNames("""["a", "", " ", null]""")); +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/SkillSeedingWireTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/SkillSeedingWireTests.cs new file mode 100644 index 00000000..1e672090 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Runner/SkillSeedingWireTests.cs @@ -0,0 +1,141 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Runner; + +/// Verifies that ResolveConfigAsync's skill resolution (union of task/list/global, +/// filtered to what's actually installed in the registry) reaches the seeder call +/// before Claude is invoked. +public sealed class SkillSeedingWireTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly string _tempDir; + private readonly WorkerConfig _cfg; + + public SkillSeedingWireTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"cd_skillwire_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir }; + } + + public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } } + + private (TaskRunner Runner, FakeSessionSkillSeeder Seeder) BuildRunner() + { + var dbFactory = _db.CreateFactory(); + var state = TaskStateServiceBuilder.Build(dbFactory).State; + var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); + var fake = new FakeClaudeProcess((_, _, _, _, _) => + Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" })); + var seeder = new FakeSessionSkillSeeder(); + var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, + new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), + new AttachmentStore(), seeder); + return (runner, seeder); + } + + private async Task SeedInstalledSkillAsync(string name) + { + using var ctx = _db.CreateContext(); + await new SessionSkillRepository(ctx).UpsertAsync(new SessionSkillEntity + { + Name = name, + SourceUrl = "https://example.test/repo.git", + PinnedRef = "deadbeef", + Subpath = $"skills/{name}", + Description = "test skill", + AddedAt = DateTimeOffset.UtcNow, + }); + } + + [Fact] + public async Task Union_of_task_list_global_skills_reaches_seeder_when_all_installed() + { + await SeedInstalledSkillAsync("alpha"); + await SeedInstalledSkillAsync("beta"); + await SeedInstalledSkillAsync("gamma"); + + var dbFactory = _db.CreateFactory(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow }); + ctx.ListConfigs.Add(new ListConfigEntity { ListId = "l1", SessionSkills = """["beta"]""" }); + ctx.Tasks.Add(new TaskEntity + { + Id = "t1", ListId = "l1", Title = "Task", Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, SessionSkills = """["alpha"]""", + }); + // AppSettingsRepository.UpdateAsync doesn't round-trip SessionSkills yet (out of + // scope here), so seed the singleton row directly. + await new AppSettingsRepository(ctx).GetAsync(); + ctx.AppSettings.First(s => s.Id == AppSettingsEntity.SingletonId).SessionSkills = """["gamma"]"""; + await ctx.SaveChangesAsync(); + } + + var (runner, seeder) = BuildRunner(); + using (var ctx = _db.CreateContext()) + await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", CancellationToken.None); + + Assert.Single(seeder.Calls); + var call = seeder.Calls.Single(); + Assert.Equal(["alpha", "beta", "gamma"], call.SkillNames); + Assert.False(call.IsWorktree); + } + + [Fact] + public async Task Unknown_skill_names_are_dropped_before_seeding() + { + await SeedInstalledSkillAsync("known"); + + var dbFactory = _db.CreateFactory(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity + { + Id = "t1", ListId = "l1", Title = "Task", Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, SessionSkills = """["known","not-installed"]""", + }); + await ctx.SaveChangesAsync(); + } + + var (runner, seeder) = BuildRunner(); + using (var ctx = _db.CreateContext()) + await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", CancellationToken.None); + + Assert.Single(seeder.Calls); + Assert.Equal(["known"], seeder.Calls.Single().SkillNames); + } + + [Fact] + public async Task No_skills_configured_seeds_empty_list() + { + var dbFactory = _db.CreateFactory(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity + { + Id = "t1", ListId = "l1", Title = "Task", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + var (runner, seeder) = BuildRunner(); + using (var ctx = _db.CreateContext()) + await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("t1"))!, "slot-1", CancellationToken.None); + + Assert.Single(seeder.Calls); + Assert.Empty(seeder.Calls.Single().SkillNames); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs index b27ad49f..7352c403 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs @@ -45,7 +45,7 @@ public sealed class StandaloneChildrenRoutingTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); var runner = new TaskRunner(fake, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); using (var ctx = _db.CreateContext()) await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("p1"))!, "slot-1", default, alreadyClaimed: true); @@ -72,7 +72,7 @@ public sealed class StandaloneChildrenRoutingTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, - new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); + new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); using (var ctx = _db.CreateContext()) await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("solo"))!, "slot-1", default, alreadyClaimed: true); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/StartRunningGuardTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/StartRunningGuardTests.cs index 6664b54c..3e120783 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/StartRunningGuardTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/StartRunningGuardTests.cs @@ -33,7 +33,7 @@ public sealed class StartRunningGuardTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger.Instance); return new TaskRunner(claude, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, - new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); + new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs index 43674da0..98ebd6e8 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs @@ -54,7 +54,7 @@ public sealed class QueueServiceSlotGuardTests : IDisposable var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); _waker = new QueueWaker(); var picker = new QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs index 18cc7b57..5898b483 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs @@ -55,7 +55,7 @@ public sealed class QueueServiceTests : IDisposable var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder()); _waker = new QueueWaker(); var picker = new QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/Skills/SessionSkillSeederTests.cs b/tests/ClaudeDo.Worker.Tests/Skills/SessionSkillSeederTests.cs new file mode 100644 index 00000000..4f8c93de --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Skills/SessionSkillSeederTests.cs @@ -0,0 +1,135 @@ +using ClaudeDo.Worker.Skills; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Skills; + +public sealed class SessionSkillSeederTests : IDisposable +{ + private readonly string _rootDir; + private readonly string _skillsRoot; + private readonly string _workingDir; + + public SessionSkillSeederTests() + { + _rootDir = Path.Combine(Path.GetTempPath(), $"claudedo_seeder_test_{Guid.NewGuid():N}"); + _skillsRoot = Path.Combine(_rootDir, "session-skills"); + _workingDir = Path.Combine(_rootDir, "workdir"); + Directory.CreateDirectory(_skillsRoot); + Directory.CreateDirectory(_workingDir); + } + + public void Dispose() + { + try { Directory.Delete(_rootDir, recursive: true); } catch { /* best effort */ } + } + + private SessionSkillSeeder CreateSeeder() => new(NullLogger.Instance, _skillsRoot); + + private void WriteInstalledSkill(string name, string fileName = "SKILL.md", string content = "# body") + { + var dir = Path.Combine(_skillsRoot, name); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, fileName), content); + } + + [Fact] + public async Task SeedAsync_copies_skill_files_into_dot_claude_skills() + { + const string skillName = "ponytail"; + WriteInstalledSkill(skillName); + var seeder = CreateSeeder(); + + await seeder.SeedAsync(_workingDir, [skillName], isWorktree: false, CancellationToken.None); + + var destFile = Path.Combine(_workingDir, ".claude", "skills", skillName, "SKILL.md"); + Assert.True(File.Exists(destFile)); + Assert.Equal("# body", File.ReadAllText(destFile)); + } + + [Fact] + public async Task SeedAsync_empty_list_creates_no_dot_claude_dir() + { + var seeder = CreateSeeder(); + + await seeder.SeedAsync(_workingDir, [], isWorktree: false, CancellationToken.None); + + Assert.False(Directory.Exists(Path.Combine(_workingDir, ".claude"))); + } + + [Fact] + public async Task SeedAsync_missing_source_skill_logs_warning_and_does_not_throw() + { + var seeder = CreateSeeder(); + + await seeder.SeedAsync(_workingDir, ["does-not-exist"], isWorktree: false, CancellationToken.None); + + Assert.False(Directory.Exists(Path.Combine(_workingDir, ".claude", "skills", "does-not-exist"))); + } + + [Fact] + public async Task SeedAsync_re_seeding_is_idempotent_and_overwrites() + { + const string skillName = "ponytail"; + WriteInstalledSkill(skillName, content: "v1"); + var seeder = CreateSeeder(); + await seeder.SeedAsync(_workingDir, [skillName], isWorktree: false, CancellationToken.None); + + WriteInstalledSkill(skillName, content: "v2"); + await seeder.SeedAsync(_workingDir, [skillName], isWorktree: false, CancellationToken.None); + + var destFile = Path.Combine(_workingDir, ".claude", "skills", skillName, "SKILL.md"); + Assert.Equal("v2", File.ReadAllText(destFile)); + } + + [Fact] + public async Task SeedAsync_non_worktree_does_not_touch_git_exclude() + { + // _workingDir is a plain directory (no .git), so a git call would throw if attempted. + WriteInstalledSkill("ponytail"); + var seeder = CreateSeeder(); + + await seeder.SeedAsync(_workingDir, ["ponytail"], isWorktree: false, CancellationToken.None); + + // No exception means git rev-parse was correctly skipped for isWorktree=false. + Assert.True(Directory.Exists(Path.Combine(_workingDir, ".claude", "skills", "ponytail"))); + } + + [Fact] + public async Task SeedAsync_worktree_appends_exclude_line_once() + { + if (!GitRepoFixture.IsGitAvailable()) return; + + using var repo = new GitRepoFixture(); + WriteInstalledSkill("ponytail"); + var seeder = CreateSeeder(); + + await seeder.SeedAsync(repo.RepoDir, ["ponytail"], isWorktree: true, CancellationToken.None); + + var excludeFile = Path.Combine(repo.RepoDir, ".git", "info", "exclude"); + Assert.True(File.Exists(excludeFile)); + var lines = await File.ReadAllLinesAsync(excludeFile); + Assert.Single(lines, l => l.Trim() == "/.claude/skills/ponytail/"); + + // Re-seed: exclude line must not be duplicated. + await seeder.SeedAsync(repo.RepoDir, ["ponytail"], isWorktree: true, CancellationToken.None); + var linesAfter = await File.ReadAllLinesAsync(excludeFile); + Assert.Single(linesAfter, l => l.Trim() == "/.claude/skills/ponytail/"); + } + + [Fact] + public async Task SeedAsync_worktree_seeded_skill_path_is_ignored_by_git_status() + { + if (!GitRepoFixture.IsGitAvailable()) return; + + using var repo = new GitRepoFixture(); + WriteInstalledSkill("ponytail"); + var seeder = CreateSeeder(); + + await seeder.SeedAsync(repo.RepoDir, ["ponytail"], isWorktree: true, CancellationToken.None); + + var status = GitRepoFixture.RunGit(repo.RepoDir, "status", "--porcelain"); + Assert.Equal(string.Empty, status.Trim()); + } +}