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);
+ }
+ }
+}