1228 lines
54 KiB
C#
1228 lines
54 KiB
C#
using System.Globalization;
|
|
using System.Reflection;
|
|
using ClaudeDo.Data;
|
|
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.Usage;
|
|
using ClaudeDo.Worker.Usage.Interfaces;
|
|
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 record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt);
|
|
|
|
/// <summary>Git SHA the running worker was built from. Null when the build wasn't stamped
|
|
/// (e.g. a local dev build without the SourceRevisionId target).</summary>
|
|
public record WorkerBuildInfoDto(string? BuildSha);
|
|
|
|
public record AppSettingsDto(
|
|
string DefaultClaudeInstructions,
|
|
string DefaultModel,
|
|
int DefaultMaxTurns,
|
|
string DefaultPermissionMode,
|
|
int MaxParallelExecutions,
|
|
string WorktreeStrategy,
|
|
string? CentralWorktreeRoot,
|
|
bool WorktreeAutoCleanupEnabled,
|
|
int WorktreeAutoCleanupDays,
|
|
string? ReportExcludedPaths,
|
|
int StandupWeekday,
|
|
int DailyPrepMaxTasks,
|
|
List<string>? SessionSkills = null,
|
|
List<ModelPresetDto>? ModelPresets = null,
|
|
int UsageGateFiveHourPct = 80,
|
|
int UsageGateSevenDayPct = 90,
|
|
int MaxTurnsCeiling = 80,
|
|
// Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings.
|
|
int UsageThrottleFiveHourSoftPct = 50,
|
|
int UsageThrottleFiveHourHardPct = 65,
|
|
int UsageThrottleSevenDaySoftPct = 50,
|
|
int UsageThrottleSevenDayHardPct = 65);
|
|
|
|
// Per-model run defaults (effort + turn budget) edited in Settings -> General.
|
|
public record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
|
|
|
public record SessionSkillDto(
|
|
string Name,
|
|
string Description,
|
|
string SourceUrl,
|
|
string PinnedRef,
|
|
DateTimeOffset AddedAt);
|
|
|
|
public record WorktreeCleanupDto(int Removed);
|
|
public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
|
|
|
public record WorktreeOverviewDto(
|
|
string TaskId,
|
|
string TaskTitle,
|
|
ClaudeDo.Data.Models.TaskStatus TaskStatus,
|
|
string ListId,
|
|
string ListName,
|
|
string Path,
|
|
string BranchName,
|
|
string BaseCommit,
|
|
WorktreeState State,
|
|
string? DiffStat,
|
|
DateTime CreatedAt,
|
|
bool PathExistsOnDisk);
|
|
|
|
public record ForceRemoveResultDto(bool Removed, string? Reason);
|
|
public record PlanningMergeConflictStateDto(string PlanningTaskId, string SubtaskId);
|
|
public record PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
|
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
|
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
|
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
|
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
|
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
|
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
|
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
|
|
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
|
|
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
|
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
|
|
public record SeedResultDto(int Copied, int Skipped);
|
|
|
|
public record OnlineInboxStateDto(
|
|
bool Enabled,
|
|
string ApiBaseUrl,
|
|
string Authority,
|
|
string ClientId,
|
|
string Scopes,
|
|
string RedirectUri,
|
|
bool SignedIn,
|
|
int PollIntervalSeconds);
|
|
|
|
public record OnlineInboxConfigInput(
|
|
bool Enabled,
|
|
string ApiBaseUrl,
|
|
int PollIntervalSeconds,
|
|
string Authority,
|
|
string ClientId,
|
|
string Scopes,
|
|
string RedirectUri);
|
|
|
|
public record UsageLimitDto(
|
|
string Kind,
|
|
string Group,
|
|
double Percent,
|
|
string Severity,
|
|
DateTimeOffset? ResetsAt,
|
|
string? ScopeModelDisplayName,
|
|
bool IsActive);
|
|
|
|
public record UsageSnapshotDto(
|
|
double? FiveHourPercent,
|
|
DateTimeOffset? FiveHourResetsAt,
|
|
double? SevenDayPercent,
|
|
DateTimeOffset? SevenDayResetsAt,
|
|
IReadOnlyList<UsageLimitDto> Limits,
|
|
int FiveHourThresholdPct,
|
|
int SevenDayThresholdPct,
|
|
bool IsGateBlocked,
|
|
string? GateReason,
|
|
DateTime? FetchedAtUtc,
|
|
bool IsStale,
|
|
string? LastError,
|
|
int ConfiguredSlots,
|
|
int EffectiveSlots,
|
|
string? ThrottleBucket,
|
|
// Throttle stages per bucket, so the usage monitor can draw (and drag) them on each gauge.
|
|
int ThrottleFiveHourSoftPct,
|
|
int ThrottleFiveHourHardPct,
|
|
int ThrottleSevenDaySoftPct,
|
|
int ThrottleSevenDayHardPct);
|
|
|
|
public record ModelUsageRowDto(
|
|
DateOnly Date,
|
|
string Model,
|
|
string Scope,
|
|
long InputTokens,
|
|
long OutputTokens,
|
|
long CacheReadTokens,
|
|
long CacheCreationTokens,
|
|
int Messages);
|
|
|
|
public record TaskUsageRowDto(
|
|
string TaskId,
|
|
string TaskTitle,
|
|
string ListId,
|
|
string ListName,
|
|
string? Model,
|
|
int Runs,
|
|
long TokensIn,
|
|
long TokensOut);
|
|
|
|
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|
{
|
|
private static readonly string Version =
|
|
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
|
|
|
|
// SourceRevisionId (set by ClaudeDo.Worker.csproj's git-rev-parse build target) is appended
|
|
// by the SDK to InformationalVersion as "+{sha}" -- take the segment after the LAST '+' so
|
|
// this stays correct even when MinVer's own pre-release metadata already contains a '+'.
|
|
private static readonly string? BuildSha = ParseBuildSha(
|
|
Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion);
|
|
|
|
internal static string? ParseBuildSha(string? informationalVersion)
|
|
{
|
|
if (string.IsNullOrEmpty(informationalVersion)) return null;
|
|
var plusIndex = informationalVersion.LastIndexOf('+');
|
|
if (plusIndex < 0 || plusIndex == informationalVersion.Length - 1) return null;
|
|
return informationalVersion[(plusIndex + 1)..];
|
|
}
|
|
|
|
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 IPrimeScheduleSignal _primeSignal;
|
|
private readonly IPrimeRunner _primeRunner;
|
|
private readonly ITaskStateService _state;
|
|
private readonly IWeekReportService _report;
|
|
private readonly IRefineRunner _refineRunner;
|
|
private readonly WorkerConfig _cfg;
|
|
private readonly OnlineInboxConfig _onlineInboxConfig;
|
|
private readonly OnlineTokenStore _onlineTokenStore;
|
|
private readonly Runner.PendingQuestionRegistry _pendingQuestions;
|
|
private readonly LogRingBuffer? _logBuffer;
|
|
private readonly ISessionSkillRegistry _skillRegistry;
|
|
private readonly IInteractiveLaunchSpecService? _interactiveLaunchSpec;
|
|
private readonly WorktreeManager? _worktreeManager;
|
|
private readonly Data.Git.GitService? _git;
|
|
private readonly UsageSnapshotBuilder? _usageSnapshotBuilder;
|
|
private readonly ITranscriptUsageReader? _usageReader;
|
|
private readonly UsageMonitorService? _usageMonitor;
|
|
|
|
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,
|
|
IPrimeScheduleSignal primeSignal,
|
|
IPrimeRunner primeRunner,
|
|
ITaskStateService state,
|
|
IWeekReportService report,
|
|
IRefineRunner refineRunner,
|
|
WorkerConfig cfg,
|
|
OnlineInboxConfig onlineInboxConfig,
|
|
OnlineTokenStore onlineTokenStore,
|
|
Runner.PendingQuestionRegistry pendingQuestions,
|
|
ISessionSkillRegistry skillRegistry,
|
|
LogRingBuffer? logBuffer = null,
|
|
IInteractiveLaunchSpecService? interactiveLaunchSpec = null,
|
|
WorktreeManager? worktreeManager = null,
|
|
Data.Git.GitService? git = null,
|
|
UsageSnapshotBuilder? usageSnapshotBuilder = null,
|
|
ITranscriptUsageReader? usageReader = null,
|
|
UsageMonitorService? usageMonitor = 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;
|
|
_pendingQuestions = pendingQuestions;
|
|
_skillRegistry = skillRegistry;
|
|
_logBuffer = logBuffer;
|
|
_interactiveLaunchSpec = interactiveLaunchSpec;
|
|
_worktreeManager = worktreeManager;
|
|
_git = git;
|
|
_usageSnapshotBuilder = usageSnapshotBuilder;
|
|
_usageReader = usageReader;
|
|
_usageMonitor = usageMonitor;
|
|
}
|
|
|
|
// 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}";
|
|
|
|
/// <summary>Lets the UI detect a worker that's still running an older build than the code it's
|
|
/// comparing against (e.g. a just-merged main) — a separate diagnostic rather than extending
|
|
/// Ping's "pong vX.Y.Z" text, since that format isn't ours to break for any existing caller.</summary>
|
|
public WorkerBuildInfoDto GetWorkerBuildInfo() => new(BuildSha);
|
|
|
|
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.
|
|
public async Task DeleteTask(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
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);
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
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)).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)
|
|
=> HubGuard(async () =>
|
|
{
|
|
var r = await _mergeService.MergeAsync(
|
|
taskId,
|
|
targetBranch ?? "",
|
|
removeWorktree,
|
|
string.IsNullOrWhiteSpace(commitMessage) ? "Merge task" : commitMessage,
|
|
CancellationToken.None);
|
|
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);
|
|
});
|
|
|
|
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, "Merge task",
|
|
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;
|
|
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();
|
|
|
|
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand 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,
|
|
});
|
|
}
|
|
|
|
await _broadcaster.ListUpdated(dto.ListId);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
public async Task SetTaskStatus(string taskId, string status)
|
|
{
|
|
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
|
throw new HubException($"unknown status: {status}");
|
|
// 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");
|
|
}
|
|
|
|
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
|
|
=> HubGuard(async () =>
|
|
{
|
|
bool hasChildren;
|
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(CancellationToken.None))
|
|
hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == taskId, CancellationToken.None);
|
|
|
|
if (hasChildren)
|
|
{
|
|
await _planningMergeOrchestrator.StartAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
|
return new MergeResultDto(TaskMergeService.StatusMerged, Array.Empty<string>(), null);
|
|
}
|
|
|
|
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
|
if (r.Status == TaskMergeService.StatusBlocked)
|
|
throw new HubException(r.ErrorMessage ?? "approve failed");
|
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
|
});
|
|
|
|
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));
|
|
|
|
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 in a task's worktree -- same worktree prep as an
|
|
// autonomous run (session-skills seeding, run env vars), --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.");
|
|
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted);
|
|
});
|
|
|
|
// Builds the launch spec for an ad-hoc interactive session in an arbitrary directory --
|
|
// no task, no worktree, no session-skills seeding.
|
|
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory) => HubGuard(() =>
|
|
{
|
|
if (_interactiveLaunchSpec is null)
|
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
|
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, 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.
|
|
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId) => HubGuard(() =>
|
|
{
|
|
if (_interactiveLaunchSpec is null)
|
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
|
return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, 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) => HubGuard(() =>
|
|
{
|
|
if (_interactiveLaunchSpec is null)
|
|
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
|
return _interactiveLaunchSpec.BuildForMergeHelperHandoffAsync(taskId, survivingTaskIds, Context.ConnectionAborted);
|
|
});
|
|
|
|
// 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 () =>
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var taskRepo = new TaskRepository(ctx);
|
|
var task = await taskRepo.GetByIdAsync(taskId, Context.ConnectionAborted)
|
|
?? throw new KeyNotFoundException();
|
|
if (task.Status is TaskStatus.Running or TaskStatus.Queued)
|
|
throw new InvalidOperationException("Can't submit a running or queued task — interrupt it first.");
|
|
if (task.Status is TaskStatus.WaitingForReview or TaskStatus.WaitingForChildren)
|
|
throw new InvalidOperationException("Task is already awaiting review.");
|
|
|
|
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted);
|
|
if (worktree is not null)
|
|
{
|
|
if (_worktreeManager is null)
|
|
throw new InvalidOperationException("Worktree manager is not configured.");
|
|
if (worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
|
|
throw new InvalidOperationException("This task has no active worktree to submit.");
|
|
if (!Directory.Exists(worktree.Path))
|
|
throw new InvalidOperationException("The task's worktree directory no longer exists.");
|
|
|
|
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, Context.ConnectionAborted)
|
|
?? throw new InvalidOperationException("Task list not found.");
|
|
|
|
var wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
|
|
await _worktreeManager.CommitIfChangedAsync(wtCtx, task, list, Context.ConnectionAborted);
|
|
}
|
|
else if (task.HandlerBaseCommit is { Length: > 0 })
|
|
{
|
|
// Worktree-less "list handler" host task (Mission Control's "Let Claude handle it"):
|
|
// the handler commits its own changes straight to the list's working dir, so there
|
|
// is nothing for us to commit here — just stamp the review range's head commit.
|
|
if (_git is null)
|
|
throw new InvalidOperationException("Git service is not configured.");
|
|
|
|
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, Context.ConnectionAborted)
|
|
?? throw new InvalidOperationException("Task list not found.");
|
|
if (string.IsNullOrEmpty(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
|
|
throw new InvalidOperationException("The list's working directory no longer exists.");
|
|
|
|
var headCommit = await _git.RevParseHeadAsync(list.WorkingDir, Context.ConnectionAborted);
|
|
await taskRepo.SetHandlerHeadCommitAsync(taskId, headCommit, Context.ConnectionAborted);
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException("This task has no active worktree to submit.");
|
|
}
|
|
|
|
var result = await _state.SubmitInteractiveForReviewAsync(taskId, DateTime.UtcNow, Context.ConnectionAborted);
|
|
if (!result.Ok)
|
|
throw new InvalidOperationException(result.Reason ?? "Could not submit for review.");
|
|
});
|
|
|
|
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)).ToList();
|
|
}
|
|
|
|
public async Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto)
|
|
{
|
|
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,
|
|
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);
|
|
}
|
|
|
|
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 async Task<bool> RunDailyPrepNow()
|
|
{
|
|
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
|
|
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(n => new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder)).ToList();
|
|
}
|
|
|
|
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 new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
#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(OnlineInboxConfigInput 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<IReadOnlyList<ModelUsageRowDto>> GetModelUsage(DateOnly from, DateOnly to) => HubGuard(async () =>
|
|
{
|
|
if (_usageReader is null)
|
|
throw new InvalidOperationException("Transcript usage reader is not configured.");
|
|
var rows = await _usageReader.ReadAsync(from, to, Context.ConnectionAborted);
|
|
return (IReadOnlyList<ModelUsageRowDto>)rows
|
|
.Select(r => new ModelUsageRowDto(
|
|
r.Date, r.Model, r.Scope == UsageScope.ClaudeDo ? "claudedo" : "other",
|
|
r.InputTokens, r.OutputTokens, r.CacheReadTokens, r.CacheCreationTokens, r.Messages))
|
|
.ToList();
|
|
});
|
|
|
|
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);
|
|
|
|
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;
|
|
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)));
|
|
})
|
|
.OrderByDescending(r => r.TokensIn + r.TokensOut)
|
|
.Take(100)
|
|
.ToList();
|
|
}
|
|
}
|