feat(worker): session skills SignalR surface + per-level persistence

This commit is contained in:
Mika Kuns
2026-07-23 16:47:14 +02:00
committed by mika kuns
parent 4626481359
commit b4c58087d2
18 changed files with 397 additions and 19 deletions
@@ -64,6 +64,7 @@ public sealed class AppSettingsRepository
? null : updated.ReportExcludedPaths;
row.StandupWeekday = updated.StandupWeekday;
row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks;
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
await _context.SaveChangesAsync(ct);
}
@@ -77,6 +77,7 @@ public sealed class ListRepository
existing.SystemPrompt = config.SystemPrompt;
existing.AgentPath = config.AgentPath;
existing.MaxTurns = config.MaxTurns;
existing.SessionSkills = config.SessionSkills;
}
await _context.SaveChangesAsync(ct);
}
@@ -189,6 +189,7 @@ public sealed class TaskRepository
string? systemPrompt,
string? agentPath,
int? maxTurns = null,
string? sessionSkills = null,
CancellationToken ct = default)
{
await _context.Tasks
@@ -197,7 +198,8 @@ public sealed class TaskRepository
.SetProperty(t => t.Model, model)
.SetProperty(t => t.SystemPrompt, systemPrompt)
.SetProperty(t => t.AgentPath, agentPath)
.SetProperty(t => t.MaxTurns, maxTurns), ct);
.SetProperty(t => t.MaxTurns, maxTurns)
.SetProperty(t => t.SessionSkills, sessionSkills), ct);
}
#endregion
@@ -64,6 +64,10 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<SeedResultDto?> RestoreDefaultAgentsAsync();
Task<ListConfigDto?> GetListConfigAsync(string listId);
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
Task<List<SessionSkillDto>> GetSessionSkillsAsync();
Task<List<string>> InstallSessionSkillAsync(string url);
Task UpdateSessionSkillAsync(string sourceUrl);
Task RemoveSessionSkillAsync(string sourceUrl);
Task SetTaskStatusAsync(string taskId, TaskStatus status);
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
+24 -4
View File
@@ -481,6 +481,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("UpdateTaskAgentSettings", dto);
}
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
public Task<List<string>> InstallSessionSkillAsync(string url)
=> _hub.InvokeAsync<List<string>>("InstallSessionSkill", url);
public Task UpdateSessionSkillAsync(string sourceUrl)
=> _hub.InvokeAsync("UpdateSessionSkill", sourceUrl);
public Task RemoveSessionSkillAsync(string sourceUrl)
=> _hub.InvokeAsync("RemoveSessionSkill", sourceUrl);
public async Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
{
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
@@ -627,7 +639,15 @@ public sealed record AppSettingsDto(
int WorktreeAutoCleanupDays,
string? ReportExcludedPaths,
int StandupWeekday,
int DailyPrepMaxTasks);
int DailyPrepMaxTasks,
List<string>? SessionSkills = null);
public sealed record SessionSkillDto(
string Name,
string Description,
string SourceUrl,
string PinnedRef,
DateTimeOffset AddedAt);
public sealed record WorktreeCleanupDto(int Removed);
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
@@ -638,9 +658,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDoc
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public sealed record SeedResultDto(int Copied, int Skipped);
public sealed record WorktreeOverviewDto(
+1 -1
View File
@@ -58,7 +58,7 @@ public sealed class ConfigMcpTools
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, cancellationToken);
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, ct: cancellationToken);
await _broadcaster.TaskUpdated(taskId);
}
+57 -8
View File
@@ -15,8 +15,10 @@ using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Refine;
using ClaudeDo.Worker.Report;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Worktrees;
using System.Text.Json;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
@@ -37,7 +39,15 @@ public record AppSettingsDto(
int WorktreeAutoCleanupDays,
string? ReportExcludedPaths,
int StandupWeekday,
int DailyPrepMaxTasks);
int DailyPrepMaxTasks,
List<string>? SessionSkills = null);
public record SessionSkillDto(
string Name,
string Description,
string SourceUrl,
string PinnedRef,
DateTimeOffset AddedAt);
public record WorktreeCleanupDto(int Removed);
public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
@@ -65,9 +75,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDoc
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public record UpdateListConfigDto(string ListId, 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);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record SeedResultDto(int Copied, int Skipped);
public record OnlineInboxStateDto(
@@ -119,6 +129,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly Runner.PendingQuestionRegistry _pendingQuestions;
private readonly InteractiveSessionService _interactive;
private readonly LogRingBuffer? _logBuffer;
private readonly ISessionSkillRegistry _skillRegistry;
public WorkerHub(
QueueService queue,
@@ -145,6 +156,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
OnlineTokenStore onlineTokenStore,
Runner.PendingQuestionRegistry pendingQuestions,
InteractiveSessionService interactive,
ISessionSkillRegistry skillRegistry,
LogRingBuffer? logBuffer = null)
{
_queue = queue;
@@ -171,9 +183,23 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_onlineTokenStore = onlineTokenStore;
_pendingQuestions = pendingQuestions;
_interactive = interactive;
_skillRegistry = skillRegistry;
_logBuffer = logBuffer;
}
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
// A null/empty selection persists as null so "inherit / none" stays clean, and the
// shape matches TaskRunner.UnionSkillNames's expected JSON string array.
private static string? SkillsToJson(List<string>? names) =>
names is null or { Count: 0 } ? null : JsonSerializer.Serialize(names);
private static List<string>? SkillsFromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try { return JsonSerializer.Deserialize<List<string>>(json); }
catch (JsonException) { return null; }
}
/// <summary>Deliver the user's answer to a question a running task raised via AskUser.
/// Returns false if no matching question is still pending (already answered or timed out).</summary>
public bool AnswerTaskQuestion(string taskId, string questionId, string answer) =>
@@ -288,7 +314,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
row.WorktreeAutoCleanupDays,
row.ReportExcludedPaths,
row.StandupWeekday,
row.DailyPrepMaxTasks);
row.DailyPrepMaxTasks,
SkillsFromJson(row.SessionSkills));
}
public async Task UpdateAppSettings(AppSettingsDto dto)
@@ -310,9 +337,28 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
ReportExcludedPaths = dto.ReportExcludedPaths,
StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday,
DailyPrepMaxTasks = dto.DailyPrepMaxTasks,
SessionSkills = SkillsToJson(dto.SessionSkills),
});
}
public async Task<List<SessionSkillDto>> GetSessionSkills()
{
var rows = await _skillRegistry.ListAsync(Context.ConnectionAborted);
return rows.Select(r => new SessionSkillDto(r.Name, r.Description, r.SourceUrl, r.PinnedRef, r.AddedAt)).ToList();
}
public Task<List<string>> InstallSessionSkill(string url) => HubGuard(async () =>
{
var installed = await _skillRegistry.InstallAsync(url, Context.ConnectionAborted);
return installed.ToList();
});
public Task UpdateSessionSkill(string sourceUrl) => HubGuard(
() => _skillRegistry.UpdateAsync(sourceUrl, Context.ConnectionAborted));
public Task RemoveSessionSkill(string sourceUrl) => HubGuard(
() => _skillRegistry.RemoveAsync(sourceUrl, Context.ConnectionAborted));
public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees(string? listId = null)
{
var result = await _wtMaintenance.CleanupFinishedAsync(listId, Context.ConnectionAborted);
@@ -456,8 +502,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var model = dto.Model.NullIfBlank();
var systemPrompt = dto.SystemPrompt.NullIfBlank();
var agentPath = dto.AgentPath.NullIfBlank();
var sessionSkills = SkillsToJson(dto.SessionSkills);
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null)
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null)
{
await repo.DeleteConfigAsync(dto.ListId);
}
@@ -470,6 +517,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
SystemPrompt = systemPrompt,
AgentPath = agentPath,
MaxTurns = dto.MaxTurns,
SessionSkills = sessionSkills,
});
}
@@ -482,7 +530,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var repo = new ListRepository(ctx);
var config = await repo.GetConfigAsync(listId);
if (config is null) return null;
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns);
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills));
}
public async Task SetTaskStatus(string taskId, string status)
@@ -543,7 +591,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
dto.Model.NullIfBlank(),
dto.SystemPrompt.NullIfBlank(),
dto.AgentPath.NullIfBlank(),
dto.MaxTurns);
dto.MaxTurns,
SkillsToJson(dto.SessionSkills));
await _broadcaster.TaskUpdated(dto.TaskId);
}