Files
ClaudeDo/src/ClaudeDo.Worker/Hub/WorkerHub.cs
T

1458 lines
67 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.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;
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)
{
_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;
}
// 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>();
// 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)).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;
// other statuses keep the unconditional "set status freely" affordance.
var result = parsed == TaskStatus.Queued
? await _state.EnqueueAsync(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 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);
}
}
}