diff --git a/src/ClaudeDo.Ui/Services/OperationTiming.cs b/src/ClaudeDo.Ui/Services/OperationTiming.cs new file mode 100644 index 00000000..5cd0a95b --- /dev/null +++ b/src/ClaudeDo.Ui/Services/OperationTiming.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using ClaudeDo.Data; + +namespace ClaudeDo.Ui.Services; + +/// +/// 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. +/// +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. + } + } +} diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index e512b5dc..cf7206b4 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -277,23 +277,58 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC /// Invoke a hub method, returning default (null) when the worker is offline or errors. private async Task TryInvokeAsync(string method, params object?[] args) { - try { return await _hub.InvokeCoreAsync(method, args); } + var sw = System.Diagnostics.Stopwatch.StartNew(); + var ok = false; + try + { + var result = await _hub.InvokeCoreAsync(method, args); + ok = true; + return result; + } catch { return default; } + finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); } + } + + /// Times a void hub invoke without changing its exception/cancellation behavior. + private async Task InvokeTimedAsync(string method, Func invoke) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + var ok = false; + try + { + await invoke(); + ok = true; + } + finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); } + } + + /// Times a hub invoke that returns a value, without changing its exception/cancellation behavior. + private async Task InvokeTimedAsync(string method, Func> 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("AnswerTaskQuestion", taskId, questionId, answer); } + try { await InvokeTimedAsync("AnswerTaskQuestion", () => _hub.InvokeAsync("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 MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) { - return await _hub.InvokeAsync( - "MergeTask", taskId, targetBranch, removeWorktree, commitMessage); + return await InvokeTimedAsync("MergeTask", () => _hub.InvokeAsync( + "MergeTask", taskId, targetBranch, removeWorktree, commitMessage)); } public Task StartConflictMergeAsync(string taskId, string targetBranch) - => _hub.InvokeAsync("StartConflictMerge", taskId, targetBranch); + => InvokeTimedAsync("StartConflictMerge", () => _hub.InvokeAsync("StartConflictMerge", taskId, targetBranch)); public Task GetMergeConflictDocumentsAsync(string taskId) - => _hub.InvokeAsync("GetMergeConflictDocuments", taskId); + => InvokeTimedAsync("GetMergeConflictDocuments", () => _hub.InvokeAsync("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 ContinueConflictMergeAsync(string taskId) - => _hub.InvokeAsync("ContinueConflictMerge", taskId); + => InvokeTimedAsync("ContinueConflictMerge", () => _hub.InvokeAsync("ContinueConflictMerge", taskId)); public Task AbortConflictMergeAsync(string taskId) - => _hub.InvokeAsync("AbortConflictMerge", taskId); + => InvokeTimedAsync("AbortConflictMerge", () => _hub.InvokeAsync("AbortConflictMerge", taskId)); public Task GetMergeTargetsAsync(string taskId) => TryInvokeAsync("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> 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 RestoreDefaultAgentsAsync() - => await _hub.InvokeAsync("RestoreDefaultAgents"); + => await InvokeTimedAsync("RestoreDefaultAgents", () => _hub.InvokeAsync("RestoreDefaultAgents")); private async Task SeedActiveTasksAsync() { try { - var active = await _hub.InvokeAsync>("GetActive"); + var active = await InvokeTimedAsync>("GetActive", () => _hub.InvokeAsync>("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> GetPrimeSchedulesAsync() => await TryInvokeAsync>("ListPrimeSchedules") ?? new List(); public async Task UpsertPrimeScheduleAsync(PrimeScheduleDto dto) - => await _hub.InvokeAsync("UpsertPrimeSchedule", dto); + => await InvokeTimedAsync("UpsertPrimeSchedule", () => _hub.InvokeAsync("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("GetWeekReport", IsoDay(start), IsoDay(end)); public Task GenerateWeekReportAsync(DateOnly start, DateOnly end) - => _hub.InvokeAsync("GenerateWeekReport", IsoDay(start), IsoDay(end)); + => InvokeTimedAsync("GenerateWeekReport", () => _hub.InvokeAsync("GenerateWeekReport", IsoDay(start), IsoDay(end))); public Task RunDailyPrepNowAsync() - => _hub.InvokeAsync("RunDailyPrepNow"); + => InvokeTimedAsync("RunDailyPrepNow", () => _hub.InvokeAsync("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> GetDailyNotesAsync(DateOnly day) => await TryInvokeAsync>("GetDailyNotes", IsoDay(day)) ?? new List(); public async Task AddDailyNoteAsync(DateOnly day, string text) - => await _hub.InvokeAsync("AddDailyNote", IsoDay(day), text); + => await InvokeTimedAsync("AddDailyNote", () => _hub.InvokeAsync("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 GetLastPrepLogAsync() => await TryInvokeAsync("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 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> GetRepoImportFoldersAsync() - => await _hub.InvokeAsync>("GetRepoImportFolders"); + => await InvokeTimedAsync>("GetRepoImportFolders", () => _hub.InvokeAsync>("GetRepoImportFolders")); public Task SetRepoImportFoldersAsync(List folders) - => _hub.InvokeAsync("SetRepoImportFolders", folders); + => InvokeTimedAsync("SetRepoImportFolders", () => _hub.InvokeAsync("SetRepoImportFolders", folders)); public async Task> GetSessionSkillsAsync() => await TryInvokeAsync>("GetSessionSkills") ?? []; public Task> InstallSessionSkillAsync(string url) - => _hub.InvokeAsync>("InstallSessionSkill", url); + => InvokeTimedAsync>("InstallSessionSkill", () => _hub.InvokeAsync>("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 SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status) { - var result = await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); + var result = await InvokeTimedAsync("SetTaskStatus", () => _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString())); return result?.BaseDirty; } public async Task ApproveReviewAsync(string taskId, string targetBranch) { LastApproveTarget = targetBranch; - return await _hub.InvokeAsync("ApproveReview", taskId, targetBranch); + return await InvokeTimedAsync("ApproveReview", () => _hub.InvokeAsync("ApproveReview", taskId, targetBranch)); } public Task 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 CleanupFinishedWorktreesAsync(string? listId = null) - => await _hub.InvokeAsync("CleanupFinishedWorktrees", listId); + => await InvokeTimedAsync("CleanupFinishedWorktrees", () => _hub.InvokeAsync("CleanupFinishedWorktrees", listId)); public async Task ResetAllWorktreesAsync() - => await _hub.InvokeAsync("ResetAllWorktrees"); + => await InvokeTimedAsync("ResetAllWorktrees", () => _hub.InvokeAsync("ResetAllWorktrees")); public async Task> GetWorktreesOverviewAsync(string? listId) => await TryInvokeAsync>("GetWorktreesOverview", listId) @@ -534,7 +569,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC { try { - var ok = await _hub.InvokeAsync("SetWorktreeState", taskId, newState); + var ok = await InvokeTimedAsync("SetWorktreeState", () => _hub.InvokeAsync("SetWorktreeState", taskId, newState)); return (ok, null); } catch (HubException ex) @@ -548,63 +583,63 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC } public async Task ForceRemoveWorktreeAsync(string taskId) - => await _hub.InvokeAsync("ForceRemoveWorktree", taskId); + => await InvokeTimedAsync("ForceRemoveWorktree", () => _hub.InvokeAsync("ForceRemoveWorktree", taskId)); public async Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) - => await _hub.InvokeAsync("StartPlanningSessionAsync", taskId, ct); + => await InvokeTimedAsync("StartPlanningSessionAsync", () => _hub.InvokeAsync("StartPlanningSessionAsync", taskId, ct)); public async Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) - => await _hub.InvokeAsync("ResumePlanningSessionAsync", taskId, ct); + => await InvokeTimedAsync("ResumePlanningSessionAsync", () => _hub.InvokeAsync("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 GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default) - => await _hub.InvokeAsync("GetInteractiveLaunchSpec", taskId, ct); + => await InvokeTimedAsync("GetInteractiveLaunchSpec", () => _hub.InvokeAsync("GetInteractiveLaunchSpec", taskId, ct)); public async Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) - => await _hub.InvokeAsync("GetAdHocLaunchSpec", directory, ct); + => await InvokeTimedAsync("GetAdHocLaunchSpec", () => _hub.InvokeAsync("GetAdHocLaunchSpec", directory, ct)); public async Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, CancellationToken ct = default) - => await _hub.InvokeAsync("GetMergeHelperLaunchSpec", taskIds, listId, ct); + => await InvokeTimedAsync("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync("GetMergeHelperLaunchSpec", taskIds, listId, ct)); public async Task CreateMergeHelperTaskAsync( IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) - => await _hub.InvokeAsync("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct); + => await InvokeTimedAsync("CreateMergeHelperTask", () => _hub.InvokeAsync("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct)); public async Task GetMergeHelperHandoffLaunchSpecAsync( string taskId, IReadOnlyList survivingTaskIds, string nextPhase, CancellationToken ct = default) - => await _hub.InvokeAsync("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, nextPhase, ct); + => await InvokeTimedAsync("GetMergeHelperHandoffLaunchSpec", () => _hub.InvokeAsync("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, nextPhase, ct)); public async Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) - => await _hub.InvokeAsync("GetPlanningStartLaunchSpec", taskId, ct); + => await InvokeTimedAsync("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync("GetPlanningStartLaunchSpec", taskId, ct)); public async Task GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default) - => await _hub.InvokeAsync("GetPlanningResumeLaunchSpec", taskId, ct); + => await InvokeTimedAsync("GetPlanningResumeLaunchSpec", () => _hub.InvokeAsync("GetPlanningResumeLaunchSpec", taskId, ct)); public async Task DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default) - => await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct); + => await InvokeTimedAsync("DiscardPlanningSessionAsync", () => _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct)); public async Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) - => await _hub.InvokeAsync("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct); + => await InvokeTimedAsync("FinalizePlanningSessionAsync", () => _hub.InvokeAsync("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct)); public async Task GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) - => await _hub.InvokeAsync("GetPendingDraftCountAsync", taskId, ct); + => await InvokeTimedAsync("GetPendingDraftCountAsync", () => _hub.InvokeAsync("GetPendingDraftCountAsync", taskId, ct)); public async Task> GetPlanningAggregateAsync(string planningTaskId) => await TryInvokeAsync>("GetPlanningAggregate", planningTaskId) ?? []; public async Task BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) - => await _hub.InvokeAsync("BuildPlanningIntegrationBranch", planningTaskId, targetBranch); + => await InvokeTimedAsync("BuildPlanningIntegrationBranch", () => _hub.InvokeAsync("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> 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 GetOnlineInboxStateAsync() => TryInvokeAsync("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 GetUsageSnapshotAsync() => TryInvokeAsync("GetUsageSnapshot"); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 1133d1aa..05a18f8d 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -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) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index e0804e8a..7b55c19b 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -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(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(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( diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index da2466ba..1e52b062 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -360,6 +360,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable private async Task LoadForListAsync( ListNavItemViewModel list, CancellationToken ct, Dictionary? 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( @@ -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 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(); diff --git a/tests/ClaudeDo.Ui.Tests/Services/OperationTimingTests.cs b/tests/ClaudeDo.Ui.Tests/Services/OperationTimingTests.cs new file mode 100644 index 00000000..05a52c22 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/Services/OperationTimingTests.cs @@ -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); + } + } +}