1515 lines
70 KiB
C#
1515 lines
70 KiB
C#
using System.Globalization;
|
|
using System.Reflection;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Environment;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Agents;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Logging;
|
|
using ClaudeDo.Worker.Online;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Runner;
|
|
using ClaudeDo.Worker.Prime;
|
|
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.AzureDevOps;
|
|
using ClaudeDo.Worker.Tickets;
|
|
using ClaudeDo.Worker.Usage;
|
|
using ClaudeDo.Worker.Usage.Interfaces;
|
|
using ClaudeDo.Worker.Usage.TokenTracker;
|
|
using ClaudeDo.Worker.Worktrees;
|
|
using System.Text.Json;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Worker.Hub;
|
|
|
|
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|
{
|
|
private static readonly string Version =
|
|
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
|
|
|
|
private readonly QueueService _queue;
|
|
private readonly IQueueWaker _waker;
|
|
private readonly AgentFileService _agentService;
|
|
private readonly DefaultAgentSeeder _seeder;
|
|
private readonly HubBroadcaster _broadcaster;
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly WorktreeMaintenanceService _wtMaintenance;
|
|
private readonly TaskResetService _resetService;
|
|
private readonly TaskMergeService _mergeService;
|
|
private readonly PlanningSessionManager _planning;
|
|
private readonly ITerminalLauncher _launcher;
|
|
private readonly PlanningAggregator _planningAggregator;
|
|
private readonly PlanningMergeOrchestrator _planningMergeOrchestrator;
|
|
private readonly PlanningChainCoordinator _planningChain;
|
|
private readonly PrimeScheduleSignal _primeSignal;
|
|
private readonly IPrimeRunner _primeRunner;
|
|
private readonly ITaskStateService _state;
|
|
private readonly WeekReportService _report;
|
|
private readonly RefineRunner _refineRunner;
|
|
private readonly WorkerConfig _cfg;
|
|
private readonly OnlineInboxConfig _onlineInboxConfig;
|
|
private readonly DpapiTokenStore _onlineTokenStore;
|
|
private readonly Runner.PendingQuestionRegistry _pendingQuestions;
|
|
private readonly LogRingBuffer? _logBuffer;
|
|
private readonly ISessionSkillRegistry _skillRegistry;
|
|
private readonly InteractiveLaunchSpecService? _interactiveLaunchSpec;
|
|
private readonly WorktreeManager? _worktreeManager;
|
|
private readonly Data.Git.GitService? _git;
|
|
private readonly UsageSnapshotBuilder? _usageSnapshotBuilder;
|
|
private readonly ITranscriptUsageReader? _usageReader;
|
|
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;
|
|
private readonly HandlerRestartCoordinator? _handlerRestart;
|
|
private readonly HandlerRoundState? _roundState;
|
|
private readonly ConnectedClientTracker? _clientTracker;
|
|
private readonly RunningOperationRegistry? _operations;
|
|
private readonly AzureDevOpsPatStore? _azdoPat;
|
|
private readonly AzureDevOpsClient? _azdo;
|
|
|
|
public WorkerHub(
|
|
QueueService queue,
|
|
IQueueWaker waker,
|
|
AgentFileService agentService,
|
|
DefaultAgentSeeder seeder,
|
|
HubBroadcaster broadcaster,
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
WorktreeMaintenanceService wtMaintenance,
|
|
TaskResetService resetService,
|
|
TaskMergeService mergeService,
|
|
PlanningSessionManager planning,
|
|
ITerminalLauncher launcher,
|
|
PlanningAggregator planningAggregator,
|
|
PlanningMergeOrchestrator planningMergeOrchestrator,
|
|
PlanningChainCoordinator planningChain,
|
|
PrimeScheduleSignal primeSignal,
|
|
IPrimeRunner primeRunner,
|
|
ITaskStateService state,
|
|
WeekReportService report,
|
|
RefineRunner refineRunner,
|
|
WorkerConfig cfg,
|
|
OnlineInboxConfig onlineInboxConfig,
|
|
OnlineRefreshTokenStore onlineTokenStore,
|
|
Runner.PendingQuestionRegistry pendingQuestions,
|
|
ISessionSkillRegistry skillRegistry,
|
|
LogRingBuffer? logBuffer = null,
|
|
InteractiveLaunchSpecService? interactiveLaunchSpec = null,
|
|
WorktreeManager? worktreeManager = null,
|
|
Data.Git.GitService? git = null,
|
|
UsageSnapshotBuilder? usageSnapshotBuilder = null,
|
|
ITranscriptUsageReader? usageReader = null,
|
|
UsageMonitorService? usageMonitor = null,
|
|
InteractiveReviewSubmissionService? interactiveReviewSubmission = null,
|
|
TokenTrackerService? tokenTracker = null,
|
|
TicketSystemConfig ticketConfig = null!,
|
|
TicketPatStore ticketPat = null!,
|
|
TicketClientFactory ticketClients = null!,
|
|
TicketImportService ticketImport = null!,
|
|
HandlerRestartCoordinator? handlerRestart = null,
|
|
HandlerRoundState? roundState = null,
|
|
ConnectedClientTracker? clientTracker = null,
|
|
RunningOperationRegistry? operations = null,
|
|
AzureDevOpsPatStore? azdoPat = null,
|
|
AzureDevOpsClient? azdo = null)
|
|
{
|
|
_queue = queue;
|
|
_waker = waker;
|
|
_agentService = agentService;
|
|
_seeder = seeder;
|
|
_broadcaster = broadcaster;
|
|
_dbFactory = dbFactory;
|
|
_wtMaintenance = wtMaintenance;
|
|
_resetService = resetService;
|
|
_mergeService = mergeService;
|
|
_planning = planning;
|
|
_launcher = launcher;
|
|
_planningAggregator = planningAggregator;
|
|
_planningMergeOrchestrator = planningMergeOrchestrator;
|
|
_planningChain = planningChain;
|
|
_primeSignal = primeSignal;
|
|
_primeRunner = primeRunner;
|
|
_state = state;
|
|
_report = report;
|
|
_refineRunner = refineRunner;
|
|
_cfg = cfg;
|
|
_onlineInboxConfig = onlineInboxConfig;
|
|
_onlineTokenStore = onlineTokenStore.Store;
|
|
_pendingQuestions = pendingQuestions;
|
|
_skillRegistry = skillRegistry;
|
|
_logBuffer = logBuffer;
|
|
_interactiveLaunchSpec = interactiveLaunchSpec;
|
|
_worktreeManager = worktreeManager;
|
|
_git = git;
|
|
_usageSnapshotBuilder = usageSnapshotBuilder;
|
|
_usageReader = usageReader;
|
|
_usageMonitor = usageMonitor;
|
|
_interactiveReviewSubmission = interactiveReviewSubmission;
|
|
_tokenTracker = tokenTracker;
|
|
_ticketConfig = ticketConfig;
|
|
_ticketPat = ticketPat;
|
|
_ticketClients = ticketClients;
|
|
_ticketImport = ticketImport;
|
|
_handlerRestart = handlerRestart;
|
|
_roundState = roundState;
|
|
_clientTracker = clientTracker;
|
|
_operations = operations;
|
|
_azdoPat = azdoPat;
|
|
_azdo = azdo;
|
|
}
|
|
|
|
// Tracks connected UI clients so an out-of-band caller (external MCP's start_list_handler)
|
|
// can tell whether the broadcast it just sent had anyone to receive it.
|
|
public override Task OnConnectedAsync()
|
|
{
|
|
_clientTracker?.Increment();
|
|
return base.OnConnectedAsync();
|
|
}
|
|
|
|
public override Task OnDisconnectedAsync(Exception? exception)
|
|
{
|
|
_clientTracker?.Decrement();
|
|
return base.OnDisconnectedAsync(exception);
|
|
}
|
|
|
|
// 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) =>
|
|
_pendingQuestions.TryAnswer(taskId, questionId, answer ?? string.Empty);
|
|
|
|
/// <summary>The question a running task is currently blocked on, if any (for UI re-attach).</summary>
|
|
public PendingQuestionDto? GetPendingQuestion(string taskId)
|
|
{
|
|
var q = _pendingQuestions.Get(taskId);
|
|
return q is null ? null : new PendingQuestionDto(q.TaskId, q.QuestionId, q.Question);
|
|
}
|
|
|
|
/// <summary>Recent worker log records (last 30 min, all levels) for the Log Visualizer overlay.</summary>
|
|
public IReadOnlyList<WorkerLogRecord> GetRecentLogs() =>
|
|
_logBuffer?.Snapshot() ?? Array.Empty<WorkerLogRecord>();
|
|
|
|
/// <summary>Operations running right now — lets a connecting or reconnecting UI recover state before the started/ended events.</summary>
|
|
public IReadOnlyList<RunningOperationDto> GetActiveOperations() =>
|
|
_operations?.Snapshot() ?? Array.Empty<RunningOperationDto>();
|
|
|
|
// Maps the two exceptions service methods throw into client-facing HubExceptions:
|
|
// KeyNotFoundException -> notFoundMessage, InvalidOperationException -> its own message.
|
|
private static async Task HubGuard(Func<Task> action, string notFoundMessage = "task not found")
|
|
{
|
|
try { await action(); }
|
|
catch (KeyNotFoundException) { throw new HubException(notFoundMessage); }
|
|
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
|
|
}
|
|
|
|
private static async Task<T> HubGuard<T>(Func<Task<T>> action, string notFoundMessage = "task not found")
|
|
{
|
|
try { return await action(); }
|
|
catch (KeyNotFoundException) { throw new HubException(notFoundMessage); }
|
|
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
|
|
}
|
|
|
|
public async Task QueuePlanningSubtasksAsync(string parentTaskId)
|
|
{
|
|
try
|
|
{
|
|
await _planningChain.QueuePlanAsync(parentTaskId, Context.ConnectionAborted);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
throw new HubException(ex.Message);
|
|
}
|
|
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var childIds = await ctx.Tasks
|
|
.Where(t => t.ParentTaskId == parentTaskId)
|
|
.Select(t => t.Id)
|
|
.ToListAsync();
|
|
await _broadcaster.TaskUpdated(parentTaskId);
|
|
foreach (var id in childIds)
|
|
await _broadcaster.TaskUpdated(id);
|
|
}
|
|
|
|
public string Ping() => $"pong v{Version}";
|
|
|
|
public IReadOnlyList<ActiveTaskDto> GetActive()
|
|
{
|
|
return _queue.GetActive()
|
|
.Select(a => new ActiveTaskDto(a.slot, a.taskId, a.startedAt))
|
|
.ToList();
|
|
}
|
|
|
|
public async Task RunNow(string taskId)
|
|
{
|
|
try
|
|
{
|
|
await _queue.RunNow(taskId);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
throw new HubException("override slot busy");
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
throw new HubException("task not found");
|
|
}
|
|
}
|
|
|
|
public Task<string> ContinueTask(string taskId, string followUpPrompt)
|
|
=> HubGuard(() => _queue.ContinueTask(taskId, followUpPrompt));
|
|
|
|
public Task ResetTask(string taskId)
|
|
=> HubGuard(() => _resetService.ResetAsync(taskId, CancellationToken.None));
|
|
|
|
public bool CancelTask(string taskId) => _queue.CancelTask(taskId);
|
|
|
|
// Mirrors ExternalMcpService.DeleteTask so a UI-initiated delete gets the same
|
|
// TryAdvanceParentAsync side effect — a direct-repo delete from the details pane
|
|
// used to skip it, permanently wedging a WaitingForChildren parent whose last
|
|
// child was deleted from there.
|
|
// No CancellationToken parameter: SignalR can't bind one on a non-streaming method and fails
|
|
// every invocation with a generic server error before the body runs.
|
|
public async Task DeleteTask(string taskId)
|
|
{
|
|
var cancellationToken = CancellationToken.None;
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
|
var repo = new TaskRepository(ctx);
|
|
var task = await repo.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new HubException("task not found");
|
|
if (task.Status == TaskStatus.Running)
|
|
throw new HubException("Cannot delete a running task. Cancel it first.");
|
|
|
|
try
|
|
{
|
|
await repo.DeleteAsync(taskId, cancellationToken);
|
|
}
|
|
// TaskRepository.DeleteAsync uses ExecuteDeleteAsync, which bypasses SaveChanges and
|
|
// surfaces provider errors directly as SqliteException rather than DbUpdateException.
|
|
catch (Exception ex) when (
|
|
(ex is Microsoft.Data.Sqlite.SqliteException || ex.InnerException is Microsoft.Data.Sqlite.SqliteException)
|
|
&& (ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|
|
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true))
|
|
{
|
|
throw new HubException("This task has child tasks. Discard the planning session or delete child tasks first.");
|
|
}
|
|
|
|
if (task.ParentTaskId is not null)
|
|
await _state.TryAdvanceParentAsync(task.ParentTaskId);
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
}
|
|
|
|
public void WakeQueue() => _waker.Wake();
|
|
|
|
public async Task<List<AgentInfo>> GetAgents() => await _agentService.ScanAsync();
|
|
|
|
public async Task RefreshAgents() => await _agentService.ScanAsync();
|
|
|
|
public async Task<SeedResultDto> RestoreDefaultAgents()
|
|
{
|
|
var result = await _seeder.SeedMissingAsync();
|
|
return new SeedResultDto(result.Copied, result.Skipped);
|
|
}
|
|
|
|
public async Task<AppSettingsDto> GetAppSettings()
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var row = await new AppSettingsRepository(ctx).GetAsync();
|
|
return new AppSettingsDto(
|
|
row.DefaultClaudeInstructions,
|
|
row.DefaultModel,
|
|
row.DefaultMaxTurns,
|
|
row.DefaultPermissionMode,
|
|
row.MaxParallelExecutions,
|
|
row.WorktreeStrategy,
|
|
row.CentralWorktreeRoot,
|
|
row.WorktreeAutoCleanupEnabled,
|
|
row.WorktreeAutoCleanupDays,
|
|
row.ReportExcludedPaths,
|
|
row.StandupWeekday,
|
|
row.DailyPrepMaxTasks,
|
|
SkillsFromJson(row.SessionSkills),
|
|
Data.Models.ModelPresets.Parse(row.ModelPresets)
|
|
.Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList(),
|
|
row.UsageGateFiveHourPct,
|
|
row.UsageGateSevenDayPct,
|
|
row.MaxTurnsCeiling,
|
|
row.UsageThrottleFiveHourSoftPct,
|
|
row.UsageThrottleFiveHourHardPct,
|
|
row.UsageThrottleSevenDaySoftPct,
|
|
row.UsageThrottleSevenDayHardPct,
|
|
row.AutoContinueOnUsageLimit,
|
|
row.PowerUseEnabled,
|
|
row.ListHandlerMaxHandoffs,
|
|
row.PowerUseWindowStart,
|
|
row.PowerUseWindowEnd,
|
|
row.PowerUseHandoffPct);
|
|
}
|
|
|
|
public async Task UpdateAppSettings(AppSettingsDto dto)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var repo = new AppSettingsRepository(ctx);
|
|
await repo.UpdateAsync(new AppSettingsEntity
|
|
{
|
|
Id = AppSettingsEntity.SingletonId,
|
|
DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "",
|
|
DefaultModel = dto.DefaultModel ?? ModelRegistry.DefaultAlias,
|
|
DefaultMaxTurns = dto.DefaultMaxTurns,
|
|
DefaultPermissionMode = dto.DefaultPermissionMode ?? PermissionModeRegistry.DefaultMode,
|
|
MaxParallelExecutions = dto.MaxParallelExecutions,
|
|
WorktreeStrategy = dto.WorktreeStrategy ?? "sibling",
|
|
CentralWorktreeRoot = dto.CentralWorktreeRoot,
|
|
WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled,
|
|
WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays,
|
|
ReportExcludedPaths = dto.ReportExcludedPaths,
|
|
StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday,
|
|
DailyPrepMaxTasks = dto.DailyPrepMaxTasks,
|
|
SessionSkills = SkillsToJson(dto.SessionSkills),
|
|
// Normalized on the way in (unknown models dropped, effort validated, turns clamped).
|
|
ModelPresets = dto.ModelPresets is { Count: > 0 }
|
|
? Data.Models.ModelPresets.Serialize(
|
|
dto.ModelPresets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns)))
|
|
: Data.Models.ModelPresets.SerializeDefaults(),
|
|
UsageGateFiveHourPct = dto.UsageGateFiveHourPct,
|
|
UsageGateSevenDayPct = dto.UsageGateSevenDayPct,
|
|
MaxTurnsCeiling = dto.MaxTurnsCeiling,
|
|
UsageThrottleFiveHourSoftPct = dto.UsageThrottleFiveHourSoftPct,
|
|
UsageThrottleFiveHourHardPct = dto.UsageThrottleFiveHourHardPct,
|
|
UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct,
|
|
UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct,
|
|
AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit,
|
|
PowerUseEnabled = dto.PowerUseEnabled,
|
|
// The cap is what bounds a runaway handoff loop; a 0 from a stale or hand-written
|
|
// client would remove that bound entirely, so clamp instead of trusting the wire value.
|
|
ListHandlerMaxHandoffs = Math.Max(1, dto.ListHandlerMaxHandoffs),
|
|
// An unparseable time string is stored as null (= no window) rather than rejected: the
|
|
// window is an optional narrowing, and a typo must not silently mean "PowerUse never
|
|
// applies". Below 50% a handoff threshold would fire before the session got anywhere.
|
|
PowerUseWindowStart = PowerUseWindow.Normalize(dto.PowerUseWindowStart),
|
|
PowerUseWindowEnd = PowerUseWindow.Normalize(dto.PowerUseWindowEnd),
|
|
PowerUseHandoffPct = Math.Clamp(dto.PowerUseHandoffPct, 50, 99),
|
|
});
|
|
}
|
|
|
|
public async Task<List<string>> GetRepoImportFolders()
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
return await new AppSettingsRepository(ctx).GetRepoImportFoldersAsync(Context.ConnectionAborted);
|
|
}
|
|
|
|
public async Task SetRepoImportFolders(List<string> folders)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
await new AppSettingsRepository(ctx).SetRepoImportFoldersAsync(folders ?? new List<string>(), Context.ConnectionAborted);
|
|
}
|
|
|
|
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);
|
|
foreach (var id in result.RemovedTaskIds)
|
|
await _broadcaster.WorktreeUpdated(id);
|
|
return new WorktreeCleanupDto(result.Removed);
|
|
}
|
|
|
|
public async Task<WorktreeResetDto> ResetAllWorktrees()
|
|
{
|
|
var result = await _wtMaintenance.ResetAllAsync();
|
|
foreach (var id in result.RemovedTaskIds)
|
|
await _broadcaster.WorktreeUpdated(id);
|
|
return new WorktreeResetDto(result.Removed, result.TasksAffected, result.Blocked, result.RunningTasks);
|
|
}
|
|
|
|
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverview(string? listId)
|
|
{
|
|
var rows = await _wtMaintenance.GetOverviewAsync(listId, Context.ConnectionAborted);
|
|
return rows.Select(r => new WorktreeOverviewDto(
|
|
r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName,
|
|
r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk,
|
|
r.TaskNumber, r.ContentInMain)).ToList();
|
|
}
|
|
|
|
public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var repo = new WorktreeRepository(ctx);
|
|
var existing = await repo.GetByTaskIdAsync(taskId, Context.ConnectionAborted);
|
|
if (existing is null) throw new HubException("worktree not found");
|
|
|
|
// Allowed transitions: Active -> Merged | Discarded | Kept. Terminal states are final.
|
|
if (existing.State == newState) return true;
|
|
if (existing.State != WorktreeState.Active || newState == WorktreeState.Active)
|
|
throw new HubException($"invalid worktree state transition {existing.State} -> {newState}");
|
|
|
|
await repo.SetStateAsync(taskId, newState, Context.ConnectionAborted);
|
|
await _broadcaster.WorktreeUpdated(taskId);
|
|
return true;
|
|
}
|
|
|
|
public async Task<ForceRemoveResultDto> ForceRemoveWorktree(string taskId)
|
|
{
|
|
var result = await _wtMaintenance.ForceRemoveAsync(taskId, Context.ConnectionAborted);
|
|
if (result.Removed)
|
|
await _broadcaster.WorktreeUpdated(taskId);
|
|
return new ForceRemoveResultDto(result.Removed, result.Reason);
|
|
}
|
|
|
|
public Task<MergeResultDto> MergeTask(
|
|
string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false)
|
|
=> HubGuard(async () =>
|
|
{
|
|
// A blank message is handed through deliberately — TaskMergeService builds the
|
|
// conventional default from the task's commit type and its list's name.
|
|
var r = await _mergeService.MergeAsync(
|
|
taskId,
|
|
targetBranch ?? "",
|
|
removeWorktree,
|
|
commitMessage,
|
|
leaveConflictsInTree: false,
|
|
CancellationToken.None,
|
|
autostash: autostash);
|
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
|
});
|
|
|
|
public Task<MergeTargetsDto> GetMergeTargets(string taskId)
|
|
=> HubGuard(async () =>
|
|
{
|
|
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
|
|
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty, t.IsCompanyRepo);
|
|
});
|
|
|
|
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
|
|
=> HubGuard(async () =>
|
|
{
|
|
var p = await _mergeService.PreviewAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
|
return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount);
|
|
});
|
|
|
|
public Task<MergeResultDto> StartConflictMerge(string taskId, string targetBranch)
|
|
=> HubGuard(async () =>
|
|
{
|
|
var r = await _mergeService.MergeAsync(
|
|
taskId, targetBranch ?? "", removeWorktree: false, commitMessage: "",
|
|
leaveConflictsInTree: true, CancellationToken.None);
|
|
if (r.Status == TaskMergeService.StatusBlocked)
|
|
throw new HubException(r.ErrorMessage ?? "merge blocked");
|
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
|
});
|
|
|
|
public Task<MergeConflictDocumentsDto> GetMergeConflictDocuments(string taskId)
|
|
=> HubGuard(async () =>
|
|
{
|
|
var c = await _mergeService.GetConflictDocumentsAsync(taskId, CancellationToken.None);
|
|
return new MergeConflictDocumentsDto(
|
|
c.TaskId,
|
|
c.Files.Select(f => new ConflictDocumentDto(
|
|
f.Path, f.IsBinary,
|
|
f.Segments.Select(s => new MergeSegmentDto(
|
|
s.IsConflict, s.Text, s.Ours, s.Base, s.Theirs)).ToList())).ToList());
|
|
});
|
|
|
|
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
|
|
=> HubGuard(() => _mergeService.WriteResolutionAsync(
|
|
taskId, path, resolvedContent ?? "", CancellationToken.None));
|
|
|
|
public Task<MergeResultDto> ContinueConflictMerge(string taskId)
|
|
=> HubGuard(async () =>
|
|
{
|
|
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
|
|
if (r.Status == TaskMergeService.StatusBlocked)
|
|
throw new HubException(r.ErrorMessage ?? "continue failed");
|
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
|
});
|
|
|
|
public Task AbortConflictMerge(string taskId)
|
|
=> HubGuard(async () =>
|
|
{
|
|
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
|
|
if (r.Status == TaskMergeService.StatusBlocked)
|
|
throw new HubException(r.ErrorMessage ?? "abort failed");
|
|
});
|
|
|
|
public async Task UpdateList(UpdateListDto dto)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var repo = new ListRepository(ctx);
|
|
var entity = await repo.GetByIdAsync(dto.Id);
|
|
if (entity is null) throw new HubException("list not found");
|
|
|
|
entity.Name = dto.Name;
|
|
entity.WorkingDir = string.IsNullOrWhiteSpace(dto.WorkingDir) ? null : dto.WorkingDir;
|
|
entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType;
|
|
entity.IsManual = dto.IsManual;
|
|
entity.FindingsTracked = dto.FindingsTracked;
|
|
entity.Icon = string.IsNullOrWhiteSpace(dto.Icon) ? null : dto.Icon;
|
|
entity.Color = string.IsNullOrWhiteSpace(dto.Color) ? null : dto.Color;
|
|
await repo.UpdateAsync(entity);
|
|
|
|
await _broadcaster.ListUpdated(dto.Id);
|
|
}
|
|
|
|
public async Task UpdateListConfig(UpdateListConfigDto dto)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var repo = new ListRepository(ctx);
|
|
|
|
var model = dto.Model.NullIfBlank();
|
|
var systemPrompt = dto.SystemPrompt.NullIfBlank();
|
|
var agentPath = dto.AgentPath.NullIfBlank();
|
|
var sessionSkills = SkillsToJson(dto.SessionSkills);
|
|
var verifyCommand = dto.VerifyCommand.NullIfBlank();
|
|
var permissionMode = NormalizePermissionMode(dto.PermissionMode);
|
|
|
|
// Tri-State: 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 existing = await repo.GetConfigAsync(dto.ListId);
|
|
var ticketProjectId = dto.TicketProjectId switch
|
|
{
|
|
null => existing?.TicketProjectId,
|
|
<= 0 => null,
|
|
var id => id,
|
|
};
|
|
|
|
// Gleiches Tri-State-Muster: null = gespeicherten Wert behalten, false = Opt-in widerrufen.
|
|
var powerUseAllowed = dto.PowerUseAllowed ?? existing?.PowerUseAllowed ?? false;
|
|
|
|
// Tri-State wie oben: null = behalten, "" = löschen.
|
|
var baseBranch = dto.BaseBranch is null ? existing?.BaseBranch : dto.BaseBranch.NullIfBlank();
|
|
|
|
// powerUseAllowed muss mit in die Leer-Prüfung: sonst löscht ein Save, bei dem PowerUse das
|
|
// EINZIGE gesetzte Feld ist, die Config-Zeile und damit das Opt-in gleich wieder mit.
|
|
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && ticketProjectId is null && !powerUseAllowed && baseBranch is null)
|
|
{
|
|
await repo.DeleteConfigAsync(dto.ListId);
|
|
}
|
|
else
|
|
{
|
|
await repo.SetConfigAsync(new ListConfigEntity
|
|
{
|
|
ListId = dto.ListId,
|
|
Model = model,
|
|
SystemPrompt = systemPrompt,
|
|
AgentPath = agentPath,
|
|
MaxTurns = dto.MaxTurns,
|
|
SessionSkills = sessionSkills,
|
|
VerifyCommand = verifyCommand,
|
|
PermissionMode = permissionMode,
|
|
TicketProjectId = ticketProjectId,
|
|
PowerUseAllowed = powerUseAllowed,
|
|
BaseBranch = baseBranch,
|
|
});
|
|
}
|
|
|
|
await _broadcaster.ListUpdated(dto.ListId);
|
|
}
|
|
|
|
/// Blank → null (inherit). Anything else must be a known mode: it becomes a
|
|
/// <c>--permission-mode</c> argument, and an unknown value only fails once a run starts.
|
|
private static string? NormalizePermissionMode(string? mode)
|
|
{
|
|
var value = mode.NullIfBlank();
|
|
if (value is null) return null;
|
|
if (!PermissionModeRegistry.Modes.Contains(value, StringComparer.OrdinalIgnoreCase))
|
|
throw new HubException($"unknown permission mode: {value}");
|
|
return value;
|
|
}
|
|
|
|
public async Task<ListConfigDto?> GetListConfig(string listId)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
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.PermissionMode, config.TicketProjectId, config.PowerUseAllowed, config.BaseBranch);
|
|
}
|
|
|
|
public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
|
|
{
|
|
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
|
throw new HubException($"unknown status: {status}");
|
|
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(Context.ConnectionAborted);
|
|
var current = await new TaskRepository(ctx).GetByIdAsync(taskId, Context.ConnectionAborted)
|
|
?? throw new HubException("task not found");
|
|
if (current.Status == TaskStatus.Running)
|
|
throw new HubException("Cannot change the status of a running task. Cancel it first.");
|
|
|
|
// Queueing goes through the gated transition so draft subtasks can't be queued;
|
|
// Idle goes through ResetToIdleAsync so StartedAt/FinishedAt/Result are cleared;
|
|
// other statuses keep the unconditional "set status freely" affordance.
|
|
var result = parsed switch
|
|
{
|
|
TaskStatus.Queued => await _state.EnqueueAsync(taskId, Context.ConnectionAborted),
|
|
TaskStatus.Idle => await _state.ResetToIdleAsync(taskId, Context.ConnectionAborted),
|
|
_ => await _state.ForceSetStatusAsync(taskId, parsed, Context.ConnectionAborted),
|
|
};
|
|
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
|
|
return new SetTaskStatusResultDto(
|
|
result.BaseDirty is { } w ? new BaseDirtyWarningDto(w.ModifiedCount, w.UntrackedCount) : null);
|
|
}
|
|
|
|
// Guarded "manual done toggle" affordance (task list checkbox) -- checks the expected
|
|
// starting status server-side so a concurrent picker claim isn't silently overwritten.
|
|
public async Task SetTaskDone(string taskId)
|
|
{
|
|
var result = await _state.MarkDoneAsync(taskId, DateTime.UtcNow, Context.ConnectionAborted);
|
|
if (!result.Ok) throw new HubException(result.Reason ?? "mark done failed");
|
|
}
|
|
|
|
public async Task UnsetTaskDone(string taskId)
|
|
{
|
|
var result = await _state.UnmarkDoneAsync(taskId, Context.ConnectionAborted);
|
|
if (!result.Ok) throw new HubException(result.Reason ?? "unmark done failed");
|
|
}
|
|
|
|
// Guarded "remove from queue" affordance. Called once per row (parent, then each queued
|
|
// child) by the UI so a cascade never bypasses the same-status guard.
|
|
public async Task DequeueTask(string taskId)
|
|
{
|
|
var result = await _state.DequeueToIdleAsync(taskId, Context.ConnectionAborted);
|
|
if (!result.Ok) throw new HubException(result.Reason ?? "dequeue failed");
|
|
}
|
|
|
|
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch, bool autostash = false)
|
|
=> HubGuard(async () =>
|
|
{
|
|
// Children already absorbed into the parent's unit branch need no unit merge -- the parent
|
|
// merges like a single task. Only legacy units (a Done child still Active) take the orchestrator.
|
|
bool legacyUnit;
|
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(CancellationToken.None))
|
|
legacyUnit = await UnitBranchService.HasUnabsorbedChildrenAsync(ctx, taskId, CancellationToken.None);
|
|
|
|
if (legacyUnit)
|
|
{
|
|
var unitResult = await _planningMergeOrchestrator.StartAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
|
if (unitResult.Status == TaskMergeService.StatusBlocked)
|
|
throw new HubException(unitResult.Reason ?? "approve failed");
|
|
return new MergeResultDto(unitResult.Status, Array.Empty<string>(), unitResult.Reason);
|
|
}
|
|
|
|
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None, autostash);
|
|
if (r.Status == TaskMergeService.StatusBlocked)
|
|
throw new HubException(r.ErrorMessage ?? "approve failed");
|
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage, r.PullRequestUrl);
|
|
});
|
|
|
|
public async Task RejectReviewToQueue(string taskId, string feedback)
|
|
{
|
|
var result = await _state.RejectToQueueAsync(taskId, feedback, Context.ConnectionAborted);
|
|
if (!result.Ok) throw new HubException(result.Reason ?? "reject failed");
|
|
}
|
|
|
|
public async Task RejectReviewToIdle(string taskId)
|
|
{
|
|
var result = await _state.RejectToIdleAsync(taskId, Context.ConnectionAborted);
|
|
if (!result.Ok) throw new HubException(result.Reason ?? "park failed");
|
|
}
|
|
|
|
public async Task CancelReview(string taskId)
|
|
{
|
|
var result = await _state.CancelAsync(taskId, DateTime.UtcNow, Context.ConnectionAborted);
|
|
if (!result.Ok) throw new HubException(result.Reason ?? "cancel failed");
|
|
}
|
|
|
|
public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var repo = new TaskRepository(ctx);
|
|
await repo.UpdateAgentSettingsAsync(
|
|
dto.TaskId,
|
|
dto.Model.NullIfBlank(),
|
|
dto.SystemPrompt.NullIfBlank(),
|
|
dto.AgentPath.NullIfBlank(),
|
|
dto.MaxTurns,
|
|
SkillsToJson(dto.SessionSkills),
|
|
NormalizePermissionMode(dto.PermissionMode));
|
|
|
|
await _broadcaster.TaskUpdated(dto.TaskId);
|
|
}
|
|
|
|
public async Task<PlanningSessionStartContext> StartPlanningSessionAsync(string taskId)
|
|
{
|
|
var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
|
|
try
|
|
{
|
|
await _launcher.LaunchPlanningStartAsync(ctx, Context.ConnectionAborted);
|
|
}
|
|
catch (TerminalLaunchException)
|
|
{
|
|
// Launch failed before any children could be created; force-cleanup is safe.
|
|
await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, Context.ConnectionAborted);
|
|
throw;
|
|
}
|
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
|
return ctx;
|
|
}
|
|
|
|
public async Task<PlanningSessionResumeContext> ResumePlanningSessionAsync(string taskId)
|
|
{
|
|
var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
|
|
await _launcher.LaunchPlanningResumeAsync(ctx, Context.ConnectionAborted);
|
|
return ctx;
|
|
}
|
|
|
|
// Builds the launch spec an embedded ConPTY terminal (UI process) needs to open an
|
|
// interactive Claude session for a task -- same worktree prep as an autonomous run when
|
|
// the task's list points at a git repo (session-skills seeding, run env vars), or a
|
|
// worktree-less spec opening directly in the list's WorkingDir when it isn't a repo.
|
|
// --resume if the task has a persisted session or a fresh-start spec otherwise. Guards:
|
|
// no running/queued task, and (once a worktree exists) it must be live on disk.
|
|
public Task<LaunchSpec> GetInteractiveLaunchSpec(string taskId) => HubGuard(() =>
|
|
{
|
|
if (_interactiveLaunchSpec is null)
|
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
|
_broadcaster.WorktreeUpdated(taskId);
|
|
// A task tile's paneId is its task id (ConPtyPaneViewModel.IdentityKey).
|
|
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted, paneId: taskId);
|
|
});
|
|
|
|
// Builds the launch spec for an ad-hoc interactive session in an arbitrary directory --
|
|
// no task, no worktree, no session-skills seeding. paneId is the tile's IdentityKey, rendered
|
|
// into the handoff hint.
|
|
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory, string paneId) => HubGuard(() =>
|
|
{
|
|
if (_interactiveLaunchSpec is null)
|
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
|
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, paneId, Context.ConnectionAborted);
|
|
});
|
|
|
|
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
|
|
public Task<LaunchSpec> GetSessionHandoffLaunchSpec(string paneId, string cwd, string briefPath, string? taskId) => HubGuard(() =>
|
|
{
|
|
if (_interactiveLaunchSpec is null)
|
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
|
return _interactiveLaunchSpec.BuildHandoffAsync(paneId, cwd, briefPath, taskId, Context.ConnectionAborted);
|
|
});
|
|
|
|
/// <summary>Launch spec resuming a recorded Mission Control session ("Recent sessions") in a new tile.</summary>
|
|
public Task<LaunchSpec> GetResumeSessionLaunchSpec(string sessionId, string paneId) => HubGuard(() =>
|
|
{
|
|
if (_interactiveLaunchSpec is null)
|
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
|
return _interactiveLaunchSpec.BuildResumeAsync(sessionId, paneId, Context.ConnectionAborted);
|
|
});
|
|
|
|
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
|
|
// tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label
|
|
// and cwd to that list. handlerTaskId is the id returned by CreateMergeHelperTask -- it is
|
|
// rendered into the brief so the session can call handoff_list_handler on itself. model
|
|
// overrides the triage session's model (null = default opus).
|
|
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId, string handlerTaskId, string? model) => HubGuard(() =>
|
|
{
|
|
if (_interactiveLaunchSpec is null)
|
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
|
return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, handlerTaskId, model, Context.ConnectionAborted);
|
|
});
|
|
|
|
// Creates the ClaudeDo task that owns a list-handler run, before the ConPTY tile opens --
|
|
// one task per run, never queued (Idle/IsManual). Broadcasts TaskUpdated so it shows up in
|
|
// the list immediately.
|
|
public Task<string> CreateMergeHelperTask(string[] taskIds, string listId, string title, string descriptionHeader) => HubGuard(async () =>
|
|
{
|
|
if (_interactiveLaunchSpec is null)
|
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
|
var taskId = await _interactiveLaunchSpec.CreateMergeHelperTaskAsync(
|
|
taskIds, listId, title, descriptionHeader, Context.ConnectionAborted);
|
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
|
return taskId;
|
|
});
|
|
|
|
// Builds the launch spec for the fresh ConPTY session a merge-helper run hands off to once
|
|
// Phase 2 (enhance) is done -- reuses the SAME handler task id (no new task created), only a
|
|
// new session dir + handoff kickoff naming the surviving tasks.
|
|
public Task<LaunchSpec> GetMergeHelperHandoffLaunchSpec(string taskId, string[] survivingTaskIds, string nextPhase) => HubGuard(() =>
|
|
{
|
|
if (_interactiveLaunchSpec is null)
|
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
|
return _interactiveLaunchSpec.BuildForMergeHelperHandoffAsync(taskId, survivingTaskIds, nextPhase, Context.ConnectionAborted);
|
|
});
|
|
|
|
// Reports that an embedded ConPTY session's process exited. Only the UI process owns the
|
|
// terminal, so the exit code exists nowhere else; every decision about it (was this a list
|
|
// handler, did the usage limit kill it, is PowerUse on, when does the window reset) belongs to
|
|
// HandlerRestartCoordinator. Fire-and-forget on purpose: the coordinator's task stays parked
|
|
// until the 5h reset, which is far longer than this connection or this hub instance lives, so
|
|
// it must not run under Context.ConnectionAborted either.
|
|
public void ReportInteractiveSessionExited(string taskId, int exitCode)
|
|
{
|
|
if (_handlerRestart is null) return;
|
|
_ = _handlerRestart.OnSessionExitedAsync(taskId, exitCode, CancellationToken.None);
|
|
}
|
|
|
|
// The Mission Control "abandon run" action: what a handler round queued/started that is still
|
|
// open (Queued or Running), read from the structured HandlerRoundState store rather than
|
|
// parsed out of the handler task's description. Empty when there is no round entry (7-day
|
|
// retention, or a run from before this feature) or the round has no open tasks -- the caller
|
|
// treats both as "nothing to confirm, just close the pane".
|
|
public async Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasks(string handlerTaskId)
|
|
{
|
|
var round = _roundState?.Get(handlerTaskId);
|
|
if (round is not { TaskIds.Count: > 0 }) return Array.Empty<HandlerOpenTaskDto>();
|
|
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
return await ctx.Tasks.AsNoTracking()
|
|
.Where(t => round.TaskIds.Contains(t.Id)
|
|
&& (t.Status == TaskStatus.Queued || t.Status == TaskStatus.Running))
|
|
.Select(t => new HandlerOpenTaskDto(t.Id, t.Title))
|
|
.ToListAsync();
|
|
}
|
|
|
|
// Starts a planning session (worktree + prompt files + token, task -> Planning) and returns
|
|
// the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for
|
|
// StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started
|
|
// session is discarded (no children exist yet), mirroring StartPlanningSessionAsync.
|
|
public Task<LaunchSpec> GetPlanningStartLaunchSpec(string taskId) => HubGuard(async () =>
|
|
{
|
|
if (_interactiveLaunchSpec is null)
|
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
|
var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
|
|
try
|
|
{
|
|
var spec = _interactiveLaunchSpec.BuildPlanningStart(ctx);
|
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
|
return spec;
|
|
}
|
|
catch
|
|
{
|
|
await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, Context.ConnectionAborted);
|
|
throw;
|
|
}
|
|
});
|
|
|
|
// Resumes a planning session and returns the ConPTY launch spec (--permission-mode plan
|
|
// --resume). ConPTY replacement for ResumePlanningSessionAsync's external wt window.
|
|
public Task<LaunchSpec> GetPlanningResumeLaunchSpec(string taskId) => HubGuard(async () =>
|
|
{
|
|
if (_interactiveLaunchSpec is null)
|
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
|
var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
|
|
return _interactiveLaunchSpec.BuildPlanningResume(ctx);
|
|
});
|
|
|
|
// Submits an interactively-worked task for review, then transitions Idle/Failed ->
|
|
// WaitingForReview. The normal Approve flow then merges it (or, for a worktree-less host
|
|
// task below, just flips to Done — there's nothing to merge). This is the only path that
|
|
// flips a hand-driven session into the review pipeline — a ConPTY session never touches
|
|
// task status on its own.
|
|
public Task SubmitTaskForReview(string taskId) => HubGuard(async () =>
|
|
{
|
|
if (_interactiveReviewSubmission is null)
|
|
throw new InvalidOperationException("Interactive review submission service is not configured.");
|
|
await _interactiveReviewSubmission.SubmitAsync(taskId, Context.ConnectionAborted);
|
|
});
|
|
|
|
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false)
|
|
{
|
|
var outcome = await _planning.DiscardAsync(taskId, dequeueQueuedChildren, Context.ConnectionAborted);
|
|
if (outcome.Result == DiscardPlanningResult.Discarded)
|
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
|
return outcome;
|
|
}
|
|
|
|
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true)
|
|
{
|
|
var count = await _planning.FinalizeAsync(taskId, queueAgentTasks, Context.ConnectionAborted);
|
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
|
return count;
|
|
}
|
|
|
|
public Task<int> GetPendingDraftCountAsync(string taskId)
|
|
=> _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted);
|
|
|
|
public Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregate(string planningTaskId)
|
|
=> HubGuard<IReadOnlyList<SubtaskDiffDto>>(async () =>
|
|
{
|
|
var diffs = await _planningAggregator.GetAggregatedDiffAsync(planningTaskId, CancellationToken.None);
|
|
return diffs.Select(d => new SubtaskDiffDto(
|
|
d.SubtaskId, d.Title, d.BranchName, d.BaseCommit, d.HeadCommit, d.DiffStat, d.UnifiedDiff)).ToList();
|
|
}, "planning task not found");
|
|
|
|
public Task<CombinedDiffResultDto> BuildPlanningIntegrationBranch(string planningTaskId, string targetBranch)
|
|
=> HubGuard(async () =>
|
|
{
|
|
var result = await _planningAggregator.BuildIntegrationBranchAsync(
|
|
planningTaskId, targetBranch ?? "", CancellationToken.None);
|
|
return result switch
|
|
{
|
|
CombinedDiffResult.Ok ok => new CombinedDiffResultDto(
|
|
true, ok.Value.IntegrationBranch, ok.Value.UnifiedDiff, null, null),
|
|
CombinedDiffResult.Failed f => new CombinedDiffResultDto(
|
|
false, null, null, f.Value.FirstConflictSubtaskId, f.Value.ConflictedFiles),
|
|
_ => throw new InvalidOperationException("unknown result type"),
|
|
};
|
|
}, "planning task not found");
|
|
|
|
public async Task ContinuePlanningMerge(string planningTaskId)
|
|
{
|
|
try { await _planningMergeOrchestrator.ContinueAsync(planningTaskId, CancellationToken.None); }
|
|
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
|
|
}
|
|
|
|
public async Task AbortPlanningMerge(string planningTaskId)
|
|
{
|
|
try { await _planningMergeOrchestrator.AbortAsync(planningTaskId, CancellationToken.None); }
|
|
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
|
|
}
|
|
|
|
/// <summary>Unit merges currently paused on a conflict that an MCP session (not the UI)
|
|
/// started. The UI calls this on (re)connect to recover the "don't auto-open the resolver"
|
|
/// banner after a restart, since the one-shot PlanningMergeConflict broadcast isn't replayed.</summary>
|
|
public async Task<List<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflicts()
|
|
{
|
|
var conflicts = await _planningMergeOrchestrator.GetActiveExternalConflictsAsync(CancellationToken.None);
|
|
return conflicts
|
|
.Select(c => new PlanningMergeConflictStateDto(c.PlanningTaskId, c.SubtaskId))
|
|
.ToList();
|
|
}
|
|
|
|
public async Task<List<PrimeScheduleDto>> ListPrimeSchedules()
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var rows = await new PrimeScheduleRepository(ctx).ListAsync();
|
|
return rows.Select(e => new PrimeScheduleDto(
|
|
e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride, e.Kind)).ToList();
|
|
}
|
|
|
|
public async Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto)
|
|
{
|
|
var error = PrimeScheduleValidation.Validate(dto);
|
|
if (error is not null) throw new HubException(error);
|
|
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var repo = new PrimeScheduleRepository(ctx);
|
|
var existing = await repo.GetAsync(dto.Id);
|
|
var entity = new ClaudeDo.Data.Models.PrimeScheduleEntity
|
|
{
|
|
Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id,
|
|
Days = (ClaudeDo.Data.Models.PrimeDays)dto.Days,
|
|
TimeOfDay = dto.TimeOfDay,
|
|
Enabled = dto.Enabled,
|
|
PromptOverride = dto.PromptOverride,
|
|
Kind = dto.Kind,
|
|
CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
|
|
LastRunAt = existing?.LastRunAt,
|
|
};
|
|
await repo.UpsertAsync(entity);
|
|
_primeSignal.Signal();
|
|
return new PrimeScheduleDto(entity.Id, (int)entity.Days, entity.TimeOfDay,
|
|
entity.Enabled, entity.LastRunAt, entity.PromptOverride, entity.Kind);
|
|
}
|
|
|
|
public async Task DeletePrimeSchedule(Guid id)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
await new PrimeScheduleRepository(ctx).DeleteAsync(id);
|
|
_primeSignal.Signal();
|
|
}
|
|
|
|
public Task RefineTask(string taskId)
|
|
{
|
|
_ = _refineRunner.RefineAsync(taskId, CancellationToken.None);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<bool> RunDailyPrepNow() => RunPrimeNowAsync(ClaudeDo.Data.Models.PrimeActionKind.FillMyDay);
|
|
|
|
/// <summary>The "Tickets + plan day" button: ticket import, then FillMyDay, through the same Prime gate.</summary>
|
|
public Task<bool> RunTicketImportNow() => RunPrimeNowAsync(ClaudeDo.Data.Models.PrimeActionKind.ImportTickets);
|
|
|
|
// A button means its own kind no matter how the schedules are configured — hence a synthetic schedule.
|
|
private async Task<bool> RunPrimeNowAsync(ClaudeDo.Data.Models.PrimeActionKind kind)
|
|
{
|
|
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null, kind);
|
|
var firedAt = DateTimeOffset.Now;
|
|
var outcome = await _primeRunner.FireAsync(schedule, Context.ConnectionAborted);
|
|
await _broadcaster.PrimeFired(Guid.Empty, outcome.Success, outcome.Message, firedAt);
|
|
return outcome.Success;
|
|
}
|
|
|
|
private static DateOnly Day(string iso) => DateOnly.ParseExact(iso, "yyyy-MM-dd", CultureInfo.InvariantCulture);
|
|
|
|
public Task<string?> GetWeekReport(string startIso, string endIso) =>
|
|
_report.GetStoredAsync(Day(startIso), Day(endIso));
|
|
|
|
public Task<string> GenerateWeekReport(string startIso, string endIso) =>
|
|
HubGuard(() => _report.GenerateAsync(Day(startIso), Day(endIso)), "report generation failed");
|
|
|
|
public async Task<List<DailyNoteDto>> GetDailyNotes(string dayIso)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var notes = await new DailyNoteRepository(ctx).ListByDayAsync(Day(dayIso));
|
|
return notes.Select(Dto).ToList();
|
|
}
|
|
|
|
/// <summary>Both ends inclusive — the editor loads today plus its recent history in one call.</summary>
|
|
public async Task<List<DailyNoteDto>> GetDailyNotesBetween(string startIso, string endIso)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var notes = await new DailyNoteRepository(ctx).ListBetweenAsync(Day(startIso), Day(endIso));
|
|
return notes.Select(Dto).ToList();
|
|
}
|
|
|
|
private static DailyNoteDto Dto(Data.Models.DailyNoteEntity n) =>
|
|
new(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder, n.FromAgent);
|
|
|
|
public async Task<DailyNoteDto> AddDailyNote(string dayIso, string text)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var n = await new DailyNoteRepository(ctx).AddAsync(Day(dayIso), text);
|
|
return Dto(n);
|
|
}
|
|
|
|
public async Task UpdateDailyNote(string id, string text)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
await new DailyNoteRepository(ctx).UpdateAsync(id, text);
|
|
}
|
|
|
|
public async Task DeleteDailyNote(string id)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
await new DailyNoteRepository(ctx).DeleteAsync(id);
|
|
}
|
|
|
|
public Task<string> GetLastPrepLog()
|
|
{
|
|
var path = DailyPrepPrompt.LogPath();
|
|
if (!File.Exists(path)) return Task.FromResult(string.Empty);
|
|
|
|
const int maxBytes = 256 * 1024;
|
|
var bytes = File.ReadAllBytes(path);
|
|
var text = bytes.Length <= maxBytes
|
|
? System.Text.Encoding.UTF8.GetString(bytes)
|
|
: System.Text.Encoding.UTF8.GetString(bytes, bytes.Length - maxBytes, maxBytes);
|
|
return Task.FromResult(text);
|
|
}
|
|
|
|
public async Task<int> ClearMyDay()
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var ids = await ctx.Tasks.Where(t => t.IsMyDay).Select(t => t.Id).ToListAsync();
|
|
if (ids.Count == 0) return 0;
|
|
|
|
await ctx.Tasks.Where(t => t.IsMyDay)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.IsMyDay, false));
|
|
|
|
foreach (var id in ids)
|
|
await _broadcaster.TaskUpdated(id);
|
|
|
|
return ids.Count;
|
|
}
|
|
|
|
public ClaudeBinDto GetClaudeBin() => DescribeClaudeBin();
|
|
|
|
/// Blank resets to the plain "claude" command. WorkerConfig is a DI singleton and
|
|
/// ClaudeProcess resolves it per spawn, so the change applies to the next run without a
|
|
/// restart — but it is persisted too, since Load() re-reads the file on startup.
|
|
public ClaudeBinDto SetClaudeBin(string? value)
|
|
{
|
|
_cfg.ClaudeBin = string.IsNullOrWhiteSpace(value) ? "claude" : value.Trim();
|
|
_cfg.SaveClaudeBin();
|
|
_ = _broadcaster.WorkerLog($"claude_bin set to '{_cfg.ClaudeBin}'", WorkerLogLevel.Info, DateTime.UtcNow);
|
|
return DescribeClaudeBin();
|
|
}
|
|
|
|
private ClaudeBinDto DescribeClaudeBin() =>
|
|
new(_cfg.ClaudeBin, ExecutableResolver.Resolve(_cfg.ClaudeBin)?.Path);
|
|
|
|
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI calls are safe here.
|
|
public OnlineInboxStateDto GetOnlineInboxState()
|
|
{
|
|
var signedIn = _onlineTokenStore.Read() is not null;
|
|
return new OnlineInboxStateDto(
|
|
_onlineInboxConfig.Enabled,
|
|
_onlineInboxConfig.ApiBaseUrl,
|
|
_onlineInboxConfig.Zitadel.Authority,
|
|
_onlineInboxConfig.Zitadel.ClientId,
|
|
_onlineInboxConfig.Zitadel.Scopes,
|
|
_onlineInboxConfig.RedirectUri,
|
|
signedIn,
|
|
_onlineInboxConfig.PollIntervalSeconds);
|
|
}
|
|
|
|
public void SetOnlineInboxConfig(OnlineInboxConfigInputDto input)
|
|
{
|
|
_onlineInboxConfig.Enabled = input.Enabled;
|
|
_onlineInboxConfig.ApiBaseUrl = input.ApiBaseUrl ?? "";
|
|
_onlineInboxConfig.PollIntervalSeconds = input.PollIntervalSeconds;
|
|
_onlineInboxConfig.RedirectUri = input.RedirectUri ?? "http://localhost:8765/callback";
|
|
_onlineInboxConfig.Zitadel.Authority = input.Authority ?? "";
|
|
_onlineInboxConfig.Zitadel.ClientId = input.ClientId ?? "";
|
|
_onlineInboxConfig.Zitadel.Scopes = input.Scopes ?? "openid offline_access";
|
|
_cfg.SaveOnlineInbox();
|
|
}
|
|
|
|
public void SetOnlineInboxAuth(string refreshToken)
|
|
{
|
|
_onlineTokenStore.Save(refreshToken);
|
|
}
|
|
|
|
public void ClearOnlineInboxAuth()
|
|
{
|
|
_onlineTokenStore.Clear();
|
|
}
|
|
#pragma warning restore CA1416
|
|
|
|
public Task<UsageSnapshotDto> GetUsageSnapshot() => HubGuard(() =>
|
|
{
|
|
if (_usageSnapshotBuilder is null)
|
|
throw new InvalidOperationException("Usage snapshot builder is not configured.");
|
|
return _usageSnapshotBuilder.BuildAsync(Context.ConnectionAborted);
|
|
});
|
|
|
|
/// <summary>
|
|
/// Manual "refresh now" for the usage monitor. Polls the endpoint out of band and returns the
|
|
/// fresh snapshot; a refresh inside the monitor's cooldown reuses the last poll's result
|
|
/// instead of risking a 429.
|
|
/// </summary>
|
|
public Task<UsageSnapshotDto> RefreshUsage() => HubGuard(() =>
|
|
{
|
|
if (_usageMonitor is null)
|
|
throw new InvalidOperationException("Usage monitor is not configured.");
|
|
return _usageMonitor.RefreshNowAsync(Context.ConnectionAborted);
|
|
});
|
|
|
|
public Task<TokenTrackerStatusDto> GetTokenTrackerStatus() => HubGuard(async () =>
|
|
{
|
|
if (_tokenTracker is null)
|
|
return new TokenTrackerStatusDto(false, null, false, null, null,
|
|
"TokenTracker is not configured on this worker.", null, 0);
|
|
|
|
var probe = await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted);
|
|
return BuildTokenTrackerStatus(probe);
|
|
});
|
|
|
|
public Task<TokenTrackerStatusDto> RefreshTokenTracker() => HubGuard(async () =>
|
|
{
|
|
if (_tokenTracker is null)
|
|
throw new InvalidOperationException("TokenTracker is not configured.");
|
|
|
|
await _tokenTracker.RefreshAsync(Context.ConnectionAborted);
|
|
return BuildTokenTrackerStatus(await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted));
|
|
});
|
|
|
|
public Task<TokenTrackerStatusDto> InstallTokenTracker() => HubGuard(async () =>
|
|
{
|
|
if (_tokenTracker is null)
|
|
throw new InvalidOperationException("TokenTracker is not configured.");
|
|
|
|
// npm writes its progress to stderr; forwarding it line by line is what makes a
|
|
// multi-minute global install visible in the footer log strip instead of looking hung.
|
|
var progress = new Progress<string>(line =>
|
|
_ = _broadcaster.WorkerLog(line, WorkerLogLevel.Info, DateTime.UtcNow));
|
|
|
|
var result = await _tokenTracker.InstallAsync(progress, Context.ConnectionAborted);
|
|
if (!result.Ok)
|
|
await _broadcaster.WorkerLog(
|
|
result.Error ?? "TokenTracker install failed.", WorkerLogLevel.Error, DateTime.UtcNow);
|
|
|
|
return BuildTokenTrackerStatus(await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted));
|
|
});
|
|
|
|
/// <summary>
|
|
/// Brings up TokenTracker's own local dashboard (<c>tokentracker serve</c>) and hands the URL
|
|
/// back — the UI opens the browser, since that is a user-session concern. The server is a
|
|
/// child of this worker and dies with it. Never throws: a failure is a message the caller
|
|
/// flashes in the footer.
|
|
/// </summary>
|
|
public Task<TokenTrackerDashboardDto> OpenTokenTrackerDashboard() => HubGuard(async () =>
|
|
{
|
|
if (_tokenTracker is null)
|
|
return new TokenTrackerDashboardDto(false, null, "TokenTracker is not configured on this worker.");
|
|
|
|
var dashboard = await _tokenTracker.OpenDashboardAsync(Context.ConnectionAborted);
|
|
if (!dashboard.Ok)
|
|
await _broadcaster.WorkerLog(
|
|
dashboard.Error ?? "TokenTracker dashboard failed to start.",
|
|
WorkerLogLevel.Error, DateTime.UtcNow);
|
|
|
|
return new TokenTrackerDashboardDto(dashboard.Ok, dashboard.Url, dashboard.Error);
|
|
});
|
|
|
|
private TokenTrackerStatusDto BuildTokenTrackerStatus(TokenTrackerProbe probe)
|
|
{
|
|
var state = _tokenTracker!.State;
|
|
return new TokenTrackerStatusDto(
|
|
probe.Installed,
|
|
probe.Version,
|
|
probe.NodeOk,
|
|
probe.NodeVersion,
|
|
state.Export?.FetchedAtUtc,
|
|
state.LastError ?? probe.Error,
|
|
state.Export?.FormatVersion,
|
|
state.Export?.Sessions.Count ?? 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Model breakdown from the cached TokenTracker export. With no export at all we wait for one
|
|
/// (the caller already shows a spinner); with a stale one we serve it and refresh behind the
|
|
/// caller's back, because blocking a range switch on a fetch is worse than showing a
|
|
/// timestamped older number.
|
|
/// </summary>
|
|
public Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsage(DateOnly from, DateOnly to) => HubGuard(async () =>
|
|
{
|
|
if (_tokenTracker is null)
|
|
return (IReadOnlyList<ModelUsageRowDto>)Array.Empty<ModelUsageRowDto>();
|
|
|
|
if (_tokenTracker.State.Export is null)
|
|
await _tokenTracker.EnsureFreshAsync(TimeSpan.Zero, Context.ConnectionAborted);
|
|
else
|
|
_ = _tokenTracker.EnsureFreshAsync(TimeSpan.FromMinutes(15), CancellationToken.None);
|
|
|
|
var export = _tokenTracker.State.Export;
|
|
if (export is null)
|
|
return (IReadOnlyList<ModelUsageRowDto>)Array.Empty<ModelUsageRowDto>();
|
|
|
|
var claudeDoHashes = await ClaudeDoSessionHashesAsync();
|
|
|
|
return (IReadOnlyList<ModelUsageRowDto>)TokenTrackerAggregator
|
|
.ByModel(export.Sessions, from, to, claudeDoHashes)
|
|
.Select(r => new ModelUsageRowDto(
|
|
r.Date, r.Model, r.Scope,
|
|
r.InputTokens, r.OutputTokens, r.CacheReadTokens, r.CacheCreationTokens,
|
|
r.Sessions, r.CostUsd))
|
|
.ToList();
|
|
});
|
|
|
|
/// <summary>Hashes of every session one of our runs owns — the scope split rides on set
|
|
/// membership rather than the export's own <c>project_key</c>, which falls back to a bare
|
|
/// worktree GUID for exactly the sessions we care about. Note this is narrower than the old
|
|
/// cwd-based split: an interactive/planning session in a worktree has no <c>task_runs</c> row
|
|
/// and therefore counts as "other".</summary>
|
|
private async Task<HashSet<string>> ClaudeDoSessionHashesAsync()
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(Context.ConnectionAborted);
|
|
var sessionIds = await ctx.TaskRuns
|
|
.Where(r => r.SessionId != null)
|
|
.Select(r => r.SessionId!)
|
|
.Distinct()
|
|
.ToListAsync(Context.ConnectionAborted);
|
|
|
|
return sessionIds
|
|
.Select(SessionHash.ForClaudeSession)
|
|
.Where(h => h is not null)
|
|
.ToHashSet()!;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<TaskUsageRowDto>> GetTaskUsage(DateOnly from, DateOnly to)
|
|
{
|
|
var fromDt = from.ToDateTime(TimeOnly.MinValue);
|
|
var toDt = to.ToDateTime(TimeOnly.MaxValue);
|
|
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(Context.ConnectionAborted);
|
|
var runs = await ctx.TaskRuns
|
|
.Where(r => r.StartedAt != null && r.StartedAt >= fromDt && r.StartedAt <= toDt)
|
|
.ToListAsync(Context.ConnectionAborted);
|
|
|
|
if (runs.Count == 0) return Array.Empty<TaskUsageRowDto>();
|
|
|
|
var taskIds = runs.Select(r => r.TaskId).Distinct().ToList();
|
|
var tasks = await ctx.Tasks.Where(t => taskIds.Contains(t.Id)).ToListAsync(Context.ConnectionAborted);
|
|
var taskById = tasks.ToDictionary(t => t.Id);
|
|
|
|
var listIds = tasks.Select(t => t.ListId).Distinct().ToList();
|
|
var lists = await ctx.Lists.Where(l => listIds.Contains(l.Id)).ToDictionaryAsync(l => l.Id, Context.ConnectionAborted);
|
|
|
|
// Cost/retries are an enrichment, not a dependency: GetModelUsage is what triggers a
|
|
// refresh, so a missing export here just means the extras columns stay empty.
|
|
var export = _tokenTracker?.State.Export;
|
|
|
|
return runs
|
|
.Where(r => taskById.ContainsKey(r.TaskId))
|
|
.GroupBy(r => r.TaskId)
|
|
.Select(g =>
|
|
{
|
|
var task = taskById[g.Key];
|
|
var listName = lists.TryGetValue(task.ListId, out var list) ? list.Name : "";
|
|
var latestModel = g.OrderByDescending(r => r.StartedAt).First().Model;
|
|
var extras = export is null
|
|
? null
|
|
: TokenTrackerAggregator.ForSessions(export.Sessions, g.Select(r => r.SessionId));
|
|
|
|
return new TaskUsageRowDto(
|
|
task.Id,
|
|
task.Title,
|
|
task.ListId,
|
|
listName,
|
|
latestModel,
|
|
g.Count(),
|
|
g.Sum(r => (long)(r.TokensIn ?? 0)),
|
|
g.Sum(r => (long)(r.TokensOut ?? 0)),
|
|
extras?.CostUsd,
|
|
extras?.Retries,
|
|
extras?.Productive,
|
|
extras?.OneShot);
|
|
})
|
|
.OrderByDescending(r => r.TokensIn + r.TokensOut)
|
|
.Take(100)
|
|
.ToList();
|
|
}
|
|
|
|
// Liefert die EFFEKTIVE Base-URL (inkl. ergänztem Schema), nicht den rohen Feldwert: sonst
|
|
// sieht der Nutzer im Tab weiter "host.local", während der Client "http://host.local" ruft.
|
|
public async Task<TicketSettingsDto> GetTicketSettings()
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var settings = await new AppSettingsRepository(ctx).GetAsync(Context.ConnectionAborted);
|
|
return new TicketSettingsDto(_ticketConfig.BaseUrl, _ticketConfig.Token is not null, settings.TicketFallbackListId);
|
|
}
|
|
|
|
/// <summary>Sets the list that import_ticket uses when Claude passes no list; null clears it.</summary>
|
|
public async Task SetTicketFallbackList(string? listId)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
await new AppSettingsRepository(ctx).SetTicketFallbackListIdAsync(listId, Context.ConnectionAborted);
|
|
}
|
|
|
|
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);
|
|
}
|
|
// "Verbindung testen" ist die eine Stelle, an der ein Fehler eine ANTWORT ist und keine
|
|
// Ausnahme: wirft der Hub, schluckt WorkerClient.TryInvokeAsync das Ergebnis zu null und
|
|
// die UI zeigt "Worker not reachable" statt des Grundes. Deshalb hier alles einfangen.
|
|
catch (Exception ex)
|
|
{
|
|
return new TicketConnectionDto(false, null, Array.Empty<string>(), ex.Message);
|
|
}
|
|
}
|
|
|
|
public AzureDevOpsSettingsDto GetAzureDevOpsSettings() => new(_azdoPat?.Store.Exists() == true);
|
|
|
|
public void SetAzureDevOpsToken(string token)
|
|
{
|
|
if (_azdoPat is null) return;
|
|
if (string.IsNullOrWhiteSpace(token)) _azdoPat.Store.Clear();
|
|
else _azdoPat.Store.Save(token.Trim());
|
|
}
|
|
|
|
public void ClearAzureDevOpsToken() => _azdoPat?.Store.Clear();
|
|
|
|
/// <summary>Reads the first company repo's ADO repository (default branch). Errors are answers, not throws — same reason as TestTicketConnection.</summary>
|
|
public async Task<AzureDevOpsConnectionDto> TestAzureDevOpsConnection()
|
|
{
|
|
if (_azdo is null) return new AzureDevOpsConnectionDto(false, null, null, "Azure DevOps ist im Worker nicht registriert.");
|
|
try
|
|
{
|
|
var git = _git ?? new Data.Git.GitService();
|
|
List<string> dirs;
|
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(Context.ConnectionAborted))
|
|
dirs = await ctx.Lists.AsNoTracking().Where(l => l.WorkingDir != null).Select(l => l.WorkingDir!).ToListAsync(Context.ConnectionAborted);
|
|
|
|
foreach (var dir in dirs)
|
|
{
|
|
if (!Directory.Exists(dir)) continue;
|
|
var remote = await git.GetRemoteUrlAsync(dir, "origin", Context.ConnectionAborted);
|
|
if (!Git.CompanyRepo.Matches(remote, _cfg.CompanyRemotePattern) || AzureDevOpsClient.ParseRemote(remote) is not { } repo)
|
|
continue;
|
|
var branch = await _azdo.GetDefaultBranchAsync(repo, Context.ConnectionAborted);
|
|
return new AzureDevOpsConnectionDto(true, repo.Repo, branch, null);
|
|
}
|
|
return new AzureDevOpsConnectionDto(false, null, null, "Keine Liste zeigt auf ein Firmen-Repo (origin mit " + _cfg.CompanyRemotePattern + ").");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new AzureDevOpsConnectionDto(false, null, null, ex.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<List<TicketProjectDto>> GetTicketProjects()
|
|
{
|
|
var client = _ticketClients.Create();
|
|
if (client is null) return new List<TicketProjectDto>();
|
|
try
|
|
{
|
|
var projects = await client.GetProjectsAsync(Context.ConnectionAborted);
|
|
return projects.Select(p => new TicketProjectDto(p.Id, p.Title, p.DepartmentName)).ToList();
|
|
}
|
|
catch (TicketApiException ex)
|
|
{
|
|
throw new HubException(ex.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<TicketImportResultDto> ImportTickets(string listId)
|
|
{
|
|
try
|
|
{
|
|
var result = await _ticketImport.ImportAsync(listId, Context.ConnectionAborted);
|
|
// ListUpdated allein reicht nicht: die UI liest das als "Listen-Metadaten geaendert"
|
|
// und patcht nur bestehende Zeilen. Neue Tasks tauchen nur ueber TaskUpdated auf, wie
|
|
// bei jedem anderen out-of-band Creator (add_task und Co.).
|
|
foreach (var taskId in result.CreatedTaskIds)
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
await _broadcaster.ListUpdated(listId);
|
|
return new TicketImportResultDto(result.Examined, result.Created);
|
|
}
|
|
catch (TicketApiException ex)
|
|
{
|
|
throw new HubException(ex.Message);
|
|
}
|
|
}
|
|
}
|