feat(ui): add operation-timing NDJSON sink for hub invokes and bulk DB paths

Two chokepoints per P0-2: WorkerClient.InvokeTimedAsync(<T>) wraps all 63
_hub.InvokeAsync call sites (plus TryInvokeAsync), and Stopwatch blocks
around the Island VMs' bulk DB load/write paths (list load, task list load,
reorders, batch delete, reconcile tick, count refresh). Single-row reads
stay untouched per the plan's rule of thumb. No display, no behavior change.
This commit is contained in:
Mika Kuns
2026-08-12 08:31:35 +02:00
parent eca16a3506
commit a614feb96d
6 changed files with 309 additions and 105 deletions
@@ -0,0 +1,49 @@
using System.Text.Json;
using ClaudeDo.Data;
namespace ClaudeDo.Ui.Services;
/// <summary>
/// Appends one NDJSON line per timed operation (hub invoke, bulk DB read/write) to a log file, so
/// a day of normal usage yields a file that can be sorted by duration to find real outliers.
/// Never throws — a failed write is swallowed, because a measurement must never disturb the app.
/// </summary>
public sealed class OperationTiming
{
public static string DefaultPath =>
Path.Combine(Paths.AppDataRoot(), "logs", "operation-timing.ndjson");
public static OperationTiming Shared { get; } = new(DefaultPath);
private readonly string _path;
private readonly object _writeLock = new();
public OperationTiming(string filePath) => _path = filePath;
public void Record(string kind, string operation, TimeSpan elapsed, bool ok)
{
try
{
var line = JsonSerializer.Serialize(new
{
ts = DateTime.UtcNow,
kind,
op = operation,
ms = (long)elapsed.TotalMilliseconds,
ok,
});
lock (_writeLock)
{
var dir = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
File.AppendAllText(_path, line + Environment.NewLine);
}
}
catch
{
// A measurement must never disturb the app.
}
}
}
+100 -65
View File
@@ -277,23 +277,58 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
/// <summary>Invoke a hub method, returning default (null) when the worker is offline or errors.</summary>
private async Task<T?> TryInvokeAsync<T>(string method, params object?[] args)
{
try { return await _hub.InvokeCoreAsync<T>(method, args); }
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
var result = await _hub.InvokeCoreAsync<T>(method, args);
ok = true;
return result;
}
catch { return default; }
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
}
/// <summary>Times a void hub invoke without changing its exception/cancellation behavior.</summary>
private async Task InvokeTimedAsync(string method, Func<Task> invoke)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await invoke();
ok = true;
}
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
}
/// <summary>Times a hub invoke that returns a value, without changing its exception/cancellation behavior.</summary>
private async Task<T> InvokeTimedAsync<T>(string method, Func<Task<T>> invoke)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
var result = await invoke();
ok = true;
return result;
}
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
}
public async Task RunNowAsync(string taskId)
{
await _hub.InvokeAsync("RunNow", taskId);
await InvokeTimedAsync("RunNow", () => _hub.InvokeAsync("RunNow", taskId));
}
public async Task ContinueTaskAsync(string taskId, string followUpPrompt)
{
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
await InvokeTimedAsync("ContinueTask", () => _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt));
}
public async Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer)
{
try { await _hub.InvokeAsync<bool>("AnswerTaskQuestion", taskId, questionId, answer); }
try { await InvokeTimedAsync<bool>("AnswerTaskQuestion", () => _hub.InvokeAsync<bool>("AnswerTaskQuestion", taskId, questionId, answer)); }
catch { /* offline or already resolved — the UI clears optimistically */ }
}
@@ -302,43 +337,43 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task ResetTaskAsync(string taskId)
{
await _hub.InvokeAsync("ResetTask", taskId);
await InvokeTimedAsync("ResetTask", () => _hub.InvokeAsync("ResetTask", taskId));
}
public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
{
return await _hub.InvokeAsync<MergeResultDto>(
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
return await InvokeTimedAsync<MergeResultDto>("MergeTask", () => _hub.InvokeAsync<MergeResultDto>(
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage));
}
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
=> InvokeTimedAsync<MergeResultDto>("StartConflictMerge", () => _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch));
public Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
=> _hub.InvokeAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", taskId);
=> InvokeTimedAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", () => _hub.InvokeAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", taskId));
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
=> InvokeTimedAsync("WriteConflictResolution", () => _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent));
public Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
=> _hub.InvokeAsync<MergeResultDto>("ContinueConflictMerge", taskId);
=> InvokeTimedAsync<MergeResultDto>("ContinueConflictMerge", () => _hub.InvokeAsync<MergeResultDto>("ContinueConflictMerge", taskId));
public Task AbortConflictMergeAsync(string taskId)
=> _hub.InvokeAsync("AbortConflictMerge", taskId);
=> InvokeTimedAsync("AbortConflictMerge", () => _hub.InvokeAsync("AbortConflictMerge", taskId));
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
public async Task CancelTaskAsync(string taskId)
{
await _hub.InvokeAsync("CancelTask", taskId);
await InvokeTimedAsync("CancelTask", () => _hub.InvokeAsync("CancelTask", taskId));
}
public async Task<(bool Ok, string? Error)> DeleteTaskAsync(string taskId)
{
try
{
await _hub.InvokeAsync("DeleteTask", taskId, CancellationToken.None);
await InvokeTimedAsync("DeleteTask", () => _hub.InvokeAsync("DeleteTask", taskId, CancellationToken.None));
return (true, null);
}
catch (HubException ex)
@@ -354,7 +389,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task WakeQueueAsync()
{
await _hub.InvokeAsync("WakeQueue");
await InvokeTimedAsync("WakeQueue", () => _hub.InvokeAsync("WakeQueue"));
}
public async Task<List<AgentInfo>> GetAgentsAsync()
@@ -362,17 +397,17 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task RefreshAgentsAsync()
{
await _hub.InvokeAsync("RefreshAgents");
await InvokeTimedAsync("RefreshAgents", () => _hub.InvokeAsync("RefreshAgents"));
}
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
=> await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
=> await InvokeTimedAsync<SeedResultDto>("RestoreDefaultAgents", () => _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents"));
private async Task SeedActiveTasksAsync()
{
try
{
var active = await _hub.InvokeAsync<List<ActiveTaskDto>>("GetActive");
var active = await InvokeTimedAsync<List<ActiveTaskDto>>("GetActive", () => _hub.InvokeAsync<List<ActiveTaskDto>>("GetActive"));
Dispatcher.UIThread.Post(() =>
{
ActiveTasks.Clear();
@@ -402,18 +437,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task UpdateAppSettingsAsync(AppSettingsDto dto)
{
await _hub.InvokeAsync("UpdateAppSettings", dto);
await InvokeTimedAsync("UpdateAppSettings", () => _hub.InvokeAsync("UpdateAppSettings", dto));
}
public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
=> await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>();
public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
=> await _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto);
=> await InvokeTimedAsync<PrimeScheduleDto>("UpsertPrimeSchedule", () => _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto));
public async Task DeletePrimeScheduleAsync(Guid id)
{
try { await _hub.InvokeAsync("DeletePrimeSchedule", id); }
try { await InvokeTimedAsync("DeletePrimeSchedule", () => _hub.InvokeAsync("DeletePrimeSchedule", id)); }
catch { /* offline */ }
}
@@ -423,27 +458,27 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
=> TryInvokeAsync<string>("GetWeekReport", IsoDay(start), IsoDay(end));
public Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end)
=> _hub.InvokeAsync<string>("GenerateWeekReport", IsoDay(start), IsoDay(end));
=> InvokeTimedAsync<string>("GenerateWeekReport", () => _hub.InvokeAsync<string>("GenerateWeekReport", IsoDay(start), IsoDay(end)));
public Task<bool> RunDailyPrepNowAsync()
=> _hub.InvokeAsync<bool>("RunDailyPrepNow");
=> InvokeTimedAsync<bool>("RunDailyPrepNow", () => _hub.InvokeAsync<bool>("RunDailyPrepNow"));
public Task RefineTaskAsync(string taskId) => _hub.InvokeAsync("RefineTask", taskId);
public Task RefineTaskAsync(string taskId) => InvokeTimedAsync("RefineTask", () => _hub.InvokeAsync("RefineTask", taskId));
public Task ClearMyDayAsync()
=> _hub.InvokeAsync("ClearMyDay");
=> InvokeTimedAsync("ClearMyDay", () => _hub.InvokeAsync("ClearMyDay"));
public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day)
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>();
public async Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text)
=> await _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text);
=> await InvokeTimedAsync<DailyNoteDto>("AddDailyNote", () => _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text));
public async Task UpdateDailyNoteAsync(string id, string text)
=> await _hub.InvokeAsync("UpdateDailyNote", id, text);
=> await InvokeTimedAsync("UpdateDailyNote", () => _hub.InvokeAsync("UpdateDailyNote", id, text));
public async Task DeleteDailyNoteAsync(string id)
=> await _hub.InvokeAsync("DeleteDailyNote", id);
=> await InvokeTimedAsync("DeleteDailyNote", () => _hub.InvokeAsync("DeleteDailyNote", id));
public async Task<string> GetLastPrepLogAsync()
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
@@ -456,12 +491,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task UpdateListAsync(UpdateListDto dto)
{
await _hub.InvokeAsync("UpdateList", dto);
await InvokeTimedAsync("UpdateList", () => _hub.InvokeAsync("UpdateList", dto));
}
public async Task UpdateListConfigAsync(UpdateListConfigDto dto)
{
await _hub.InvokeAsync("UpdateListConfig", dto);
await InvokeTimedAsync("UpdateListConfig", () => _hub.InvokeAsync("UpdateListConfig", dto));
}
public Task<ListConfigDto?> GetListConfigAsync(string listId)
@@ -469,37 +504,37 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto)
{
await _hub.InvokeAsync("UpdateTaskAgentSettings", dto);
await InvokeTimedAsync("UpdateTaskAgentSettings", () => _hub.InvokeAsync("UpdateTaskAgentSettings", dto));
}
public async Task<List<string>> GetRepoImportFoldersAsync()
=> await _hub.InvokeAsync<List<string>>("GetRepoImportFolders");
=> await InvokeTimedAsync<List<string>>("GetRepoImportFolders", () => _hub.InvokeAsync<List<string>>("GetRepoImportFolders"));
public Task SetRepoImportFoldersAsync(List<string> folders)
=> _hub.InvokeAsync("SetRepoImportFolders", folders);
=> InvokeTimedAsync("SetRepoImportFolders", () => _hub.InvokeAsync("SetRepoImportFolders", folders));
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
public Task<List<string>> InstallSessionSkillAsync(string url)
=> _hub.InvokeAsync<List<string>>("InstallSessionSkill", url);
=> InvokeTimedAsync<List<string>>("InstallSessionSkill", () => _hub.InvokeAsync<List<string>>("InstallSessionSkill", url));
public Task UpdateSessionSkillAsync(string sourceUrl)
=> _hub.InvokeAsync("UpdateSessionSkill", sourceUrl);
=> InvokeTimedAsync("UpdateSessionSkill", () => _hub.InvokeAsync("UpdateSessionSkill", sourceUrl));
public Task RemoveSessionSkillAsync(string sourceUrl)
=> _hub.InvokeAsync("RemoveSessionSkill", sourceUrl);
=> InvokeTimedAsync("RemoveSessionSkill", () => _hub.InvokeAsync("RemoveSessionSkill", sourceUrl));
public async Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
{
var result = await _hub.InvokeAsync<SetTaskStatusResultDto>("SetTaskStatus", taskId, status.ToString());
var result = await InvokeTimedAsync<SetTaskStatusResultDto>("SetTaskStatus", () => _hub.InvokeAsync<SetTaskStatusResultDto>("SetTaskStatus", taskId, status.ToString()));
return result?.BaseDirty;
}
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
{
LastApproveTarget = targetBranch;
return await _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
return await InvokeTimedAsync<MergeResultDto>("ApproveReview", () => _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch));
}
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
@@ -507,24 +542,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task RejectReviewToQueueAsync(string taskId, string feedback)
{
await _hub.InvokeAsync("RejectReviewToQueue", taskId, feedback);
await InvokeTimedAsync("RejectReviewToQueue", () => _hub.InvokeAsync("RejectReviewToQueue", taskId, feedback));
}
public async Task RejectReviewToIdleAsync(string taskId)
{
await _hub.InvokeAsync("RejectReviewToIdle", taskId);
await InvokeTimedAsync("RejectReviewToIdle", () => _hub.InvokeAsync("RejectReviewToIdle", taskId));
}
public async Task CancelReviewAsync(string taskId)
{
await _hub.InvokeAsync("CancelReview", taskId);
await InvokeTimedAsync("CancelReview", () => _hub.InvokeAsync("CancelReview", taskId));
}
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
=> await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
=> await InvokeTimedAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", () => _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId));
public async Task<WorktreeResetDto?> ResetAllWorktreesAsync()
=> await _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
=> await InvokeTimedAsync<WorktreeResetDto>("ResetAllWorktrees", () => _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees"));
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
=> await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId)
@@ -534,7 +569,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
{
try
{
var ok = await _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState);
var ok = await InvokeTimedAsync<bool>("SetWorktreeState", () => _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState));
return (ok, null);
}
catch (HubException ex)
@@ -548,63 +583,63 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
}
public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
=> await _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
=> await InvokeTimedAsync<ForceRemoveResultDto>("ForceRemoveWorktree", () => _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId));
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
=> await InvokeTimedAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", () => _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct));
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
=> await InvokeTimedAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", () => _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct));
public async Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("SubmitTaskForReview", taskId, ct);
=> await InvokeTimedAsync("SubmitTaskForReview", () => _hub.InvokeAsync("SubmitTaskForReview", taskId, ct));
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct);
=> await InvokeTimedAsync<LaunchSpec>("GetInteractiveLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct));
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct);
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct));
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct));
public async Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
=> await _hub.InvokeAsync<string>("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct);
=> await InvokeTimedAsync<string>("CreateMergeHelperTask", () => _hub.InvokeAsync<string>("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct));
public async Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, nextPhase, ct);
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, nextPhase, ct));
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct);
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
public async Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningResumeLaunchSpec", taskId, ct);
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningResumeLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningResumeLaunchSpec", taskId, ct));
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
=> await _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct);
=> await InvokeTimedAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", () => _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct));
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
=> await _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
=> await InvokeTimedAsync<int>("FinalizePlanningSessionAsync", () => _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct));
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
=> await InvokeTimedAsync<int>("GetPendingDraftCountAsync", () => _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct));
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
=> await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? [];
public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
=> await _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
=> await InvokeTimedAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", () => _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch));
public async Task ContinuePlanningMergeAsync(string planningTaskId)
{
await _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId);
await InvokeTimedAsync("ContinuePlanningMerge", () => _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId));
}
public async Task AbortPlanningMergeAsync(string planningTaskId)
{
await _hub.InvokeAsync("AbortPlanningMerge", planningTaskId);
await InvokeTimedAsync("AbortPlanningMerge", () => _hub.InvokeAsync("AbortPlanningMerge", planningTaskId));
}
public async Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
@@ -613,20 +648,20 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default)
{
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
await InvokeTimedAsync("QueuePlanningSubtasksAsync", () => _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct));
}
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync()
=> TryInvokeAsync<OnlineInboxStateDto>("GetOnlineInboxState");
public async Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input)
=> await _hub.InvokeAsync("SetOnlineInboxConfig", input);
=> await InvokeTimedAsync("SetOnlineInboxConfig", () => _hub.InvokeAsync("SetOnlineInboxConfig", input));
public async Task SetOnlineInboxAuthAsync(string refreshToken)
=> await _hub.InvokeAsync("SetOnlineInboxAuth", refreshToken);
=> await InvokeTimedAsync("SetOnlineInboxAuth", () => _hub.InvokeAsync("SetOnlineInboxAuth", refreshToken));
public async Task ClearOnlineInboxAuthAsync()
=> await _hub.InvokeAsync("ClearOnlineInboxAuth");
=> await InvokeTimedAsync("ClearOnlineInboxAuth", () => _hub.InvokeAsync("ClearOnlineInboxAuth"));
public Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
=> TryInvokeAsync<UsageSnapshotDto>("GetUsageSnapshot");
@@ -610,6 +610,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
@@ -701,12 +703,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
}
await Merge.RefreshMergePreviewAsync();
ok = true;
}
catch (OperationCanceledException) { }
finally { OperationTiming.Shared.Record("db", "DetailsIsland.BindAsync", sw.Elapsed, ok); }
}
private async System.Threading.Tasks.Task LoadChildOutcomesAsync(string parentTaskId, CancellationToken ct)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
@@ -717,6 +723,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
ct.ThrowIfCancellationRequested();
ok = true;
if (children.Count == 0) return;
ChildOutcomes.Clear();
@@ -749,10 +756,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
catch (OperationCanceledException) { }
catch { /* best-effort */ }
finally { OperationTiming.Shared.Record("db", "DetailsIsland.LoadChildOutcomesAsync", sw.Elapsed, ok); }
}
private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
@@ -762,6 +772,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
.Where(t => t.ParentTaskId == parentTaskId)
.ToListAsync(ct);
ct.ThrowIfCancellationRequested();
ok = true;
foreach (var child in children)
{
@@ -791,6 +802,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
catch (OperationCanceledException) { }
catch { /* best-effort */ }
finally { OperationTiming.Shared.Record("db", "DetailsIsland.LoadPlanningChildrenAsync", sw.Elapsed, ok); }
}
private async System.Threading.Tasks.Task RefreshPlanningChildAsync(string childTaskId)
@@ -255,30 +255,37 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
};
foreach (var s in smart) { Items.Add(s); SmartLists.Add(s); }
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
var dotColors = new[] { "Moss", "Peat", "Sage" };
int idx = 0;
foreach (var l in await lists.GetAllAsync(ct))
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
if (seedNames.Contains(l.Name)) continue;
var item = new ListNavItemViewModel
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
var dotColors = new[] { "Moss", "Peat", "Sage" };
int idx = 0;
foreach (var l in await lists.GetAllAsync(ct))
{
Id = $"user:{l.Id}",
Name = l.Name,
Kind = ListKind.User,
IconKey = "Folder",
DotColorKey = dotColors[idx % dotColors.Length],
WorkingDir = l.WorkingDir,
DefaultCommitType = l.DefaultCommitType,
IsManual = l.IsManual,
FindingsTracked = l.FindingsTracked,
};
Items.Add(item);
UserLists.Add(item);
idx++;
if (seedNames.Contains(l.Name)) continue;
var item = new ListNavItemViewModel
{
Id = $"user:{l.Id}",
Name = l.Name,
Kind = ListKind.User,
IconKey = "Folder",
DotColorKey = dotColors[idx % dotColors.Length],
WorkingDir = l.WorkingDir,
DefaultCommitType = l.DefaultCommitType,
IsManual = l.IsManual,
FindingsTracked = l.FindingsTracked,
};
Items.Add(item);
UserLists.Add(item);
idx++;
}
ok = true;
}
finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); }
await RefreshCountsAsync(ct);
SelectedList = Items.FirstOrDefault();
@@ -286,6 +293,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
public async Task RefreshCountsAsync(CancellationToken ct = default)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
@@ -306,9 +315,11 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var filter = _filters.Resolve(item.Id);
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
}
ok = true;
}
catch (OperationCanceledException) { throw; }
catch { /* best-effort refresh */ }
finally { OperationTiming.Shared.Record("db", "ListsIsland.RefreshCountsAsync", sw.Elapsed, ok); }
}
[RelayCommand]
@@ -382,9 +393,16 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
MoveWithinCollection(UserLists, source, target, placeBelow);
var orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList();
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
await lists.ReorderAsync(orderedIds);
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
await lists.ReorderAsync(orderedIds);
ok = true;
}
finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); }
}
private static void MoveWithinCollection(
@@ -360,6 +360,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private async Task LoadForListAsync(
ListNavItemViewModel list, CancellationToken ct, Dictionary<string, TaskRowViewModel>? reusable)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
@@ -460,8 +462,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
Regroup();
UpdateSubtitle();
ok = true;
}
catch (OperationCanceledException) { }
finally { OperationTiming.Shared.Record("db", "TasksIsland.LoadForListAsync", sw.Elapsed, ok); }
}
internal void Regroup()
@@ -816,17 +820,24 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
var listId = _currentList.Id["user:".Length..];
var orderedIds = Items.Select(i => i.Id).ToList();
await using var db = await _dbFactory.CreateDbContextAsync();
var idSet = orderedIds.ToHashSet();
var entities = await db.Tasks
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
.ToListAsync();
for (int i = 0; i < orderedIds.Count; i++)
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
var e = entities.FirstOrDefault(x => x.Id == orderedIds[i]);
if (e is not null) e.SortOrder = i;
await using var db = await _dbFactory.CreateDbContextAsync();
var idSet = orderedIds.ToHashSet();
var entities = await db.Tasks
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
.ToListAsync();
for (int i = 0; i < orderedIds.Count; i++)
{
var e = entities.FirstOrDefault(x => x.Id == orderedIds[i]);
if (e is not null) e.SortOrder = i;
}
await db.SaveChangesAsync();
ok = true;
}
await db.SaveChangesAsync();
finally { OperationTiming.Shared.Record("db", "TasksIsland.ReorderAsync", sw.Elapsed, ok); }
}
private static void MoveWithinCollection<T>(
@@ -990,17 +1001,22 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
if (!ok) return;
}
await using var db = await _dbFactory.CreateDbContextAsync();
var repo = new TaskRepository(db);
foreach (var row in toDelete)
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
try
await using var db = await _dbFactory.CreateDbContextAsync();
var repo = new TaskRepository(db);
foreach (var row in toDelete)
{
await repo.DeleteAsync(row.Id);
Items.Remove(row);
try
{
await repo.DeleteAsync(row.Id);
Items.Remove(row);
}
catch { /* still referenced by open child tasks; leave it visible */ }
}
catch { /* still referenced by open child tasks; leave it visible */ }
}
finally { OperationTiming.Shared.Record("db", "TasksIsland.ClearCompletedAsync", sw.Elapsed, ok: true); }
Regroup();
UpdateSubtitle();
@@ -1476,6 +1492,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
List<TaskEntity> entities;
var sw = System.Diagnostics.Stopwatch.StartNew();
var dbOk = false;
try
{
var idSet = ids.ToHashSet();
@@ -1485,6 +1503,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
.Include(t => t.Worktree)
.Where(t => idSet.Contains(t.Id))
.ToListAsync(ct);
dbOk = true;
}
catch (OperationCanceledException) { return; }
catch (Exception ex)
@@ -1492,6 +1511,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
System.Diagnostics.Debug.WriteLine($"TasksIsland: reconcile tick failed ({ex.Message})");
return;
}
finally { OperationTiming.Shared.Record("db", "TasksIsland.ReconcileTickAsync", sw.Elapsed, dbOk); }
if (ReconcileTickTestBarrier is { } barrier) await barrier();
@@ -0,0 +1,70 @@
using System.Text.Json;
using ClaudeDo.Ui.Services;
using Xunit;
namespace ClaudeDo.Ui.Tests.Services;
public class OperationTimingTests
{
[Fact]
public void Record_AppendsOneWellFormedNdjsonLinePerOperation()
{
var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N"));
var path = Path.Combine(dir, "operation-timing.ndjson");
var sink = new OperationTiming(path);
try
{
sink.Record("hub", "RunNow", TimeSpan.FromMilliseconds(42), ok: true);
sink.Record("db", "TasksIsland.LoadForList", TimeSpan.FromMilliseconds(7), ok: false);
var lines = File.ReadAllLines(path);
Assert.Equal(2, lines.Length);
using var doc1 = JsonDocument.Parse(lines[0]);
var root1 = doc1.RootElement;
Assert.Equal("hub", root1.GetProperty("kind").GetString());
Assert.Equal("RunNow", root1.GetProperty("op").GetString());
Assert.Equal(42, root1.GetProperty("ms").GetInt64());
Assert.True(root1.GetProperty("ok").GetBoolean());
Assert.True(root1.TryGetProperty("ts", out _));
using var doc2 = JsonDocument.Parse(lines[1]);
var root2 = doc2.RootElement;
Assert.Equal("db", root2.GetProperty("kind").GetString());
Assert.Equal("TasksIsland.LoadForList", root2.GetProperty("op").GetString());
Assert.Equal(7, root2.GetProperty("ms").GetInt64());
Assert.False(root2.GetProperty("ok").GetBoolean());
}
finally
{
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void Record_SwallowsWriteFailureInsteadOfThrowing()
{
// A plain file sitting where the sink expects a directory makes Directory.CreateDirectory
// throw -- standing in for the disk-full/locked-file/no-permission failures this sink
// must survive without disturbing the caller.
var root = Path.Combine(Path.GetTempPath(), "claudedo_optiming_invalid_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var blockingFile = Path.Combine(root, "not-a-directory");
File.WriteAllText(blockingFile, "");
var targetPath = Path.Combine(blockingFile, "timing.ndjson");
var sink = new OperationTiming(targetPath);
try
{
var exception = Record.Exception(() => sink.Record("hub", "RunNow", TimeSpan.FromMilliseconds(1), ok: true));
Assert.Null(exception);
Assert.False(File.Exists(targetPath));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
}