feat(hub): Ticket-Settings, Projektliste und Import ueber den Hub

This commit is contained in:
mika kuns
2026-08-27 13:22:54 +02:00
parent 2e236736a7
commit f0a3a186dd
9 changed files with 255 additions and 10 deletions
@@ -89,6 +89,8 @@ public sealed class ListRepository
existing.SessionSkills = config.SessionSkills;
existing.VerifyCommand = config.VerifyCommand;
existing.SerializeOnFileOverlap = config.SerializeOnFileOverlap;
existing.PermissionMode = config.PermissionMode;
existing.TicketProjectId = config.TicketProjectId;
}
await _context.SaveChangesAsync(ct);
}
+11 -5
View File
@@ -94,14 +94,15 @@ public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string?
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false);
// 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
// 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, string? PermissionMode = null);
// SerializeOnFileOverlap and TicketProjectId are tri-state on purpose: null = leave the stored
// value alone. A caller that 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. TicketProjectId's
// own scale: null = keep stored, <= 0 = clear the link, > 0 = set it.
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, int? TicketProjectId = 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, 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, string? PermissionMode = null, int? TicketProjectId = null);
public record SeedResultDto(int Copied, int Skipped);
@@ -206,3 +207,8 @@ public sealed record LaunchSpec(
string Exe,
IReadOnlyList<string> Args,
IReadOnlyDictionary<string, string> Env);
public record TicketSettingsDto(string? ApiBaseUrl, bool TokenSet);
public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList<string> Scopes, string? Error);
public record TicketProjectDto(int Id, string Title, string DepartmentName);
public record TicketImportResultDto(int Examined, int Created);
@@ -217,4 +217,15 @@ public interface IWorkerClient : INotifyPropertyChanged
/// <summary>Starts TokenTracker's local dashboard on the worker and returns its URL; opening
/// the browser is the caller's job.</summary>
Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync();
Task<TicketSettingsDto?> GetTicketSettingsAsync();
Task SetTicketApiBaseUrlAsync(string? baseUrl);
Task SetTicketTokenAsync(string token);
Task ClearTicketTokenAsync();
Task<TicketConnectionDto?> TestTicketConnectionAsync();
Task<List<TicketProjectDto>> GetTicketProjectsAsync();
/// <summary>Imports open tickets assigned to the token owner into the given list. Throws
/// HubException (with a user-readable message) on failure so the caller can surface it via
/// the footer error strip.</summary>
Task<TicketImportResultDto> ImportTicketsAsync(string listId);
}
+21
View File
@@ -706,6 +706,27 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync()
=> TryInvokeAsync<TokenTrackerDashboardDto>("OpenTokenTrackerDashboard");
public Task<TicketSettingsDto?> GetTicketSettingsAsync()
=> TryInvokeAsync<TicketSettingsDto>("GetTicketSettings");
public Task SetTicketApiBaseUrlAsync(string? baseUrl)
=> InvokeTimedAsync("SetTicketApiBaseUrl", () => _hub.InvokeAsync("SetTicketApiBaseUrl", baseUrl));
public Task SetTicketTokenAsync(string token)
=> InvokeTimedAsync("SetTicketToken", () => _hub.InvokeAsync("SetTicketToken", token));
public Task ClearTicketTokenAsync()
=> InvokeTimedAsync("ClearTicketToken", () => _hub.InvokeAsync("ClearTicketToken"));
public Task<TicketConnectionDto?> TestTicketConnectionAsync()
=> TryInvokeAsync<TicketConnectionDto>("TestTicketConnection");
public async Task<List<TicketProjectDto>> GetTicketProjectsAsync()
=> await TryInvokeAsync<List<TicketProjectDto>>("GetTicketProjects") ?? [];
public Task<TicketImportResultDto> ImportTicketsAsync(string listId)
=> InvokeTimedAsync<TicketImportResultDto>("ImportTickets", () => _hub.InvokeAsync<TicketImportResultDto>("ImportTickets", listId));
// IWorkerClient explicit implementations (drop typed return values)
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
=> await StartPlanningSessionAsync(taskId, ct);
+5 -2
View File
@@ -106,10 +106,12 @@ public sealed class ConfigMcpTools
// Fields this tool doesn't expose but that live on the same row. They must survive every
// write here — ListRepository.SetConfigAsync copies the entity verbatim, so anything left
// 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; a TicketProjectId reset silently
// kills the list<->ticket-project link — the tool has no ticket parameter on purpose,
// that link is UI-only).
var hasUnrelatedSettings = existing is not null
&& (existing.SessionSkills is not null || existing.SerializeOnFileOverlap
|| existing.PermissionMode is not null);
|| existing.PermissionMode is not null || existing.TicketProjectId is not null);
ListConfigDto? config;
var allCleared = m is null && sp is null && ap is null && mt is null && vc is null;
@@ -130,6 +132,7 @@ public sealed class ConfigMcpTools
VerifyCommand = vc, SessionSkills = existing?.SessionSkills,
SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false,
PermissionMode = existing?.PermissionMode,
TicketProjectId = existing?.TicketProjectId,
}, cancellationToken);
config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc);
}
+82 -3
View File
@@ -18,6 +18,7 @@ using ClaudeDo.Worker.Report;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Tickets;
using ClaudeDo.Worker.Usage;
using ClaudeDo.Worker.Usage.Interfaces;
using ClaudeDo.Worker.Usage.TokenTracker;
@@ -81,6 +82,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly UsageMonitorService? _usageMonitor;
private readonly TokenTrackerService? _tokenTracker;
private readonly InteractiveReviewSubmissionService? _interactiveReviewSubmission;
private readonly TicketSystemConfig _ticketConfig;
private readonly TicketPatStore _ticketPat;
private readonly TicketClientFactory _ticketClients;
private readonly TicketImportService _ticketImport;
public WorkerHub(
QueueService queue,
@@ -115,7 +120,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
ITranscriptUsageReader? usageReader = null,
UsageMonitorService? usageMonitor = null,
InteractiveReviewSubmissionService? interactiveReviewSubmission = null,
TokenTrackerService? tokenTracker = null)
TokenTrackerService? tokenTracker = null,
TicketSystemConfig ticketConfig = null!,
TicketPatStore ticketPat = null!,
TicketClientFactory ticketClients = null!,
TicketImportService ticketImport = null!)
{
_queue = queue;
_waker = waker;
@@ -150,6 +159,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_usageMonitor = usageMonitor;
_interactiveReviewSubmission = interactiveReviewSubmission;
_tokenTracker = tokenTracker;
_ticketConfig = ticketConfig;
_ticketPat = ticketPat;
_ticketClients = ticketClients;
_ticketImport = ticketImport;
}
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
@@ -553,7 +566,17 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var existing = await repo.GetConfigAsync(dto.ListId);
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 && permissionMode is null && !serializeOnFileOverlap)
// Gleiche Tri-State-Regel wie oben: null = gespeicherten Wert behalten, 0 = Verknüpfung
// löschen. Ohne das würde jeder fremde Writer (set_list_config MCP-Tool, Agent-Settings)
// die Ticket-Verknüpfung still kappen — SetConfigAsync kopiert verbatim.
var ticketProjectId = dto.TicketProjectId switch
{
null => existing?.TicketProjectId,
<= 0 => null,
var id => id,
};
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && !serializeOnFileOverlap && ticketProjectId is null)
{
await repo.DeleteConfigAsync(dto.ListId);
}
@@ -570,6 +593,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
VerifyCommand = verifyCommand,
SerializeOnFileOverlap = serializeOnFileOverlap,
PermissionMode = permissionMode,
TicketProjectId = ticketProjectId,
});
}
@@ -593,7 +617,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, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.SerializeOnFileOverlap, config.PermissionMode);
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.SerializeOnFileOverlap, config.PermissionMode, config.TicketProjectId);
}
public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
@@ -1249,4 +1273,59 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
.Take(100)
.ToList();
}
public TicketSettingsDto GetTicketSettings()
=> new(_cfg.TicketApiBaseUrl, _ticketConfig.Token is not null);
public void SetTicketApiBaseUrl(string? baseUrl)
{
_cfg.TicketApiBaseUrl = string.IsNullOrWhiteSpace(baseUrl) ? null : baseUrl.Trim();
_cfg.SaveTicketApiBaseUrl();
}
public void SetTicketToken(string token)
{
if (string.IsNullOrWhiteSpace(token)) _ticketPat.Store.Clear();
else _ticketPat.Store.Save(token.Trim());
}
public void ClearTicketToken() => _ticketPat.Store.Clear();
public async Task<TicketConnectionDto> TestTicketConnection()
{
var client = _ticketClients.Create();
if (client is null)
return new TicketConnectionDto(false, null, Array.Empty<string>(), "Base-URL oder Token fehlt.");
try
{
var identity = await client.GetIdentityAsync(Context.ConnectionAborted);
return new TicketConnectionDto(true, identity.UserName, identity.Scopes, null);
}
catch (TicketApiException ex)
{
return new TicketConnectionDto(false, null, Array.Empty<string>(), ex.Message);
}
}
public async Task<List<TicketProjectDto>> GetTicketProjects()
{
var client = _ticketClients.Create();
if (client is null) return new List<TicketProjectDto>();
var projects = await client.GetProjectsAsync(Context.ConnectionAborted);
return projects.Select(p => new TicketProjectDto(p.Id, p.Title, p.DepartmentName)).ToList();
}
public async Task<TicketImportResultDto> ImportTickets(string listId)
{
try
{
var result = await _ticketImport.ImportAsync(listId, Context.ConnectionAborted);
await _broadcaster.ListUpdated(listId);
return new TicketImportResultDto(result.Examined, result.Created);
}
catch (TicketApiException ex)
{
throw new HubException(ex.Message);
}
}
}