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.SessionSkills = config.SessionSkills;
existing.VerifyCommand = config.VerifyCommand; existing.VerifyCommand = config.VerifyCommand;
existing.SerializeOnFileOverlap = config.SerializeOnFileOverlap; existing.SerializeOnFileOverlap = config.SerializeOnFileOverlap;
existing.PermissionMode = config.PermissionMode;
existing.TicketProjectId = config.TicketProjectId;
} }
await _context.SaveChangesAsync(ct); 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); 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 // SerializeOnFileOverlap and TicketProjectId are tri-state on purpose: null = leave the stored
// doesn't own the field (anything but the list-settings modal) must not be able to clear it by // value alone. A caller that doesn't own the field (anything but the list-settings modal) must not
// omission — SetConfigAsync copies the entity verbatim. // be able to clear it by omission — SetConfigAsync copies the entity verbatim. TicketProjectId's
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); // 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 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); public record SeedResultDto(int Copied, int Skipped);
@@ -206,3 +207,8 @@ public sealed record LaunchSpec(
string Exe, string Exe,
IReadOnlyList<string> Args, IReadOnlyList<string> Args,
IReadOnlyDictionary<string, string> Env); 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 /// <summary>Starts TokenTracker's local dashboard on the worker and returns its URL; opening
/// the browser is the caller's job.</summary> /// the browser is the caller's job.</summary>
Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync(); 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() public Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync()
=> TryInvokeAsync<TokenTrackerDashboardDto>("OpenTokenTrackerDashboard"); => 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) // IWorkerClient explicit implementations (drop typed return values)
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct) async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
=> await StartPlanningSessionAsync(taskId, 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 // 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 // write here — ListRepository.SetConfigAsync copies the entity verbatim, so anything left
// 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; 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 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); || existing.PermissionMode is not null || existing.TicketProjectId 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;
@@ -130,6 +132,7 @@ public sealed class ConfigMcpTools
VerifyCommand = vc, SessionSkills = existing?.SessionSkills, VerifyCommand = vc, SessionSkills = existing?.SessionSkills,
SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false, SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false,
PermissionMode = existing?.PermissionMode, PermissionMode = existing?.PermissionMode,
TicketProjectId = existing?.TicketProjectId,
}, cancellationToken); }, cancellationToken);
config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc); 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.Report.Interfaces;
using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.State; using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Tickets;
using ClaudeDo.Worker.Usage; using ClaudeDo.Worker.Usage;
using ClaudeDo.Worker.Usage.Interfaces; using ClaudeDo.Worker.Usage.Interfaces;
using ClaudeDo.Worker.Usage.TokenTracker; using ClaudeDo.Worker.Usage.TokenTracker;
@@ -81,6 +82,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly UsageMonitorService? _usageMonitor; private readonly UsageMonitorService? _usageMonitor;
private readonly TokenTrackerService? _tokenTracker; private readonly TokenTrackerService? _tokenTracker;
private readonly InteractiveReviewSubmissionService? _interactiveReviewSubmission; private readonly InteractiveReviewSubmissionService? _interactiveReviewSubmission;
private readonly TicketSystemConfig _ticketConfig;
private readonly TicketPatStore _ticketPat;
private readonly TicketClientFactory _ticketClients;
private readonly TicketImportService _ticketImport;
public WorkerHub( public WorkerHub(
QueueService queue, QueueService queue,
@@ -115,7 +120,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
ITranscriptUsageReader? usageReader = null, ITranscriptUsageReader? usageReader = null,
UsageMonitorService? usageMonitor = null, UsageMonitorService? usageMonitor = null,
InteractiveReviewSubmissionService? interactiveReviewSubmission = null, InteractiveReviewSubmissionService? interactiveReviewSubmission = null,
TokenTrackerService? tokenTracker = null) TokenTrackerService? tokenTracker = null,
TicketSystemConfig ticketConfig = null!,
TicketPatStore ticketPat = null!,
TicketClientFactory ticketClients = null!,
TicketImportService ticketImport = null!)
{ {
_queue = queue; _queue = queue;
_waker = waker; _waker = waker;
@@ -150,6 +159,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_usageMonitor = usageMonitor; _usageMonitor = usageMonitor;
_interactiveReviewSubmission = interactiveReviewSubmission; _interactiveReviewSubmission = interactiveReviewSubmission;
_tokenTracker = tokenTracker; _tokenTracker = tokenTracker;
_ticketConfig = ticketConfig;
_ticketPat = ticketPat;
_ticketClients = ticketClients;
_ticketImport = ticketImport;
} }
// Persistence boundary for the session_skills JSON-array columns (task/list/global). // 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 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 && 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); await repo.DeleteConfigAsync(dto.ListId);
} }
@@ -570,6 +593,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
VerifyCommand = verifyCommand, VerifyCommand = verifyCommand,
SerializeOnFileOverlap = serializeOnFileOverlap, SerializeOnFileOverlap = serializeOnFileOverlap,
PermissionMode = permissionMode, PermissionMode = permissionMode,
TicketProjectId = ticketProjectId,
}); });
} }
@@ -593,7 +617,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
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, 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) public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
@@ -1249,4 +1273,59 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
.Take(100) .Take(100)
.ToList(); .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);
}
}
} }
@@ -200,6 +200,14 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync() => public virtual Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync() =>
Task.FromResult<TokenTrackerDashboardDto?>(new TokenTrackerDashboardDto(true, "http://127.0.0.1:7681/", null)); Task.FromResult<TokenTrackerDashboardDto?>(new TokenTrackerDashboardDto(true, "http://127.0.0.1:7681/", null));
public virtual Task<TicketSettingsDto?> GetTicketSettingsAsync() => Task.FromResult<TicketSettingsDto?>(null);
public virtual Task SetTicketApiBaseUrlAsync(string? baseUrl) => Task.CompletedTask;
public virtual Task SetTicketTokenAsync(string token) => Task.CompletedTask;
public virtual Task ClearTicketTokenAsync() => Task.CompletedTask;
public virtual Task<TicketConnectionDto?> TestTicketConnectionAsync() => Task.FromResult<TicketConnectionDto?>(null);
public virtual Task<List<TicketProjectDto>> GetTicketProjectsAsync() => Task.FromResult(new List<TicketProjectDto>());
public virtual Task<TicketImportResultDto> ImportTicketsAsync(string listId) => Task.FromResult(new TicketImportResultDto(0, 0));
public void RaiseUsageUpdated(UsageSnapshotDto snapshot) => UsageUpdatedEvent?.Invoke(snapshot); public void RaiseUsageUpdated(UsageSnapshotDto snapshot) => UsageUpdatedEvent?.Invoke(snapshot);
protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
@@ -0,0 +1,107 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
using Xunit;
namespace ClaudeDo.Worker.Tests.Tickets;
/// TicketProjectId shares the exact SetConfigAsync-copies-verbatim trap that SerializeOnFileOverlap
/// already hit once: any writer that doesn't carry the field forward silently clears the
/// list<->ticket-project link. UpdateListConfigDto's TicketProjectId is tri-state for the same
/// reason SerializeOnFileOverlap is: null = keep stored, <=0 = clear, >0 = set. The MCP
/// set_list_config tool doesn't expose the field at all (UI-only), so it must preserve whatever is
/// already stored.
public sealed class ListConfigTicketProjectTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
private WorkerHub CreateHub()
{
var factory = _db.CreateFactory();
var broadcaster = new HubBroadcaster(new CapturingHubContext());
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, factory,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;
}
private async Task<string> SeedListAsync()
{
var listId = Guid.NewGuid().ToString();
await using var ctx = _db.CreateContext();
await new ListRepository(ctx).AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
return listId;
}
private async Task SeedConfigAsync(string listId, int? ticketProjectId = null, string? model = null)
{
await using var ctx = _db.CreateContext();
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity
{
ListId = listId, Model = model, TicketProjectId = ticketProjectId,
});
}
private async Task<ListConfigEntity?> ReadConfigAsync(string listId)
{
await using var ctx = _db.CreateContext();
return await new ListRepository(ctx).GetConfigAsync(listId);
}
private async Task CallSetListConfigAsync(string listId, string model)
{
var factory = _db.CreateFactory();
await using var ctx = factory.CreateDbContext();
var tool = new ConfigMcpTools(
new ListRepository(ctx), new TaskRepository(ctx),
new HubBroadcaster(new CapturingHubContext()), factory);
await tool.SetListConfig(listId, model: model, cancellationToken: CancellationToken.None);
}
[Fact]
public async Task set_list_config_preserves_TicketProjectId()
{
var listId = await SeedListAsync();
await SeedConfigAsync(listId, ticketProjectId: 393);
await CallSetListConfigAsync(listId, model: "opus");
var config = await ReadConfigAsync(listId);
Assert.Equal(393, config!.TicketProjectId);
Assert.Equal("opus", config.Model);
}
[Fact]
public async Task UpdateListConfig_with_null_TicketProjectId_keeps_the_stored_value()
{
var hub = CreateHub();
var listId = await SeedListAsync();
await SeedConfigAsync(listId, ticketProjectId: 393);
await hub.UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null));
Assert.Equal(393, (await ReadConfigAsync(listId))!.TicketProjectId);
}
[Fact]
public async Task UpdateListConfig_can_clear_the_link_explicitly()
{
var hub = CreateHub();
var listId = await SeedListAsync();
await SeedConfigAsync(listId, ticketProjectId: 393);
await hub.UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null, TicketProjectId: 0));
Assert.Null((await ReadConfigAsync(listId))!.TicketProjectId);
}
}
@@ -185,6 +185,14 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync() => public Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync() =>
Task.FromResult<TokenTrackerDashboardDto?>(new TokenTrackerDashboardDto(false, null, "not configured")); Task.FromResult<TokenTrackerDashboardDto?>(new TokenTrackerDashboardDto(false, null, "not configured"));
public Task<TicketSettingsDto?> GetTicketSettingsAsync() => Task.FromResult<TicketSettingsDto?>(null);
public Task SetTicketApiBaseUrlAsync(string? baseUrl) => Task.CompletedTask;
public Task SetTicketTokenAsync(string token) => Task.CompletedTask;
public Task ClearTicketTokenAsync() => Task.CompletedTask;
public Task<TicketConnectionDto?> TestTicketConnectionAsync() => Task.FromResult<TicketConnectionDto?>(null);
public Task<List<TicketProjectDto>> GetTicketProjectsAsync() => Task.FromResult(new List<TicketProjectDto>());
public Task<TicketImportResultDto> ImportTicketsAsync(string listId) => Task.FromResult(new TicketImportResultDto(0, 0));
} }
// ── Helper to build VM with pre-seeded Items ────────────────────────────────── // ── Helper to build VM with pre-seeded Items ──────────────────────────────────