feat(claude-do): merge [P0-2] Timing-Hook für Hub-Invokes und Island-DB-Pfad

ClaudeDo-Task: a96c2c16-62e0-48f2-b80d-d98b113c7583
This commit is contained in:
Mika Kuns
2026-08-12 08:43:09 +02:00
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> /// <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) 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; } 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) 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) 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) 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 */ } 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) 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) public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
{ {
return await _hub.InvokeAsync<MergeResultDto>( return await InvokeTimedAsync<MergeResultDto>("MergeTask", () => _hub.InvokeAsync<MergeResultDto>(
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage); "MergeTask", taskId, targetBranch, removeWorktree, commitMessage));
} }
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) 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) 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) 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) public Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
=> _hub.InvokeAsync<MergeResultDto>("ContinueConflictMerge", taskId); => InvokeTimedAsync<MergeResultDto>("ContinueConflictMerge", () => _hub.InvokeAsync<MergeResultDto>("ContinueConflictMerge", taskId));
public Task AbortConflictMergeAsync(string taskId) public Task AbortConflictMergeAsync(string taskId)
=> _hub.InvokeAsync("AbortConflictMerge", taskId); => InvokeTimedAsync("AbortConflictMerge", () => _hub.InvokeAsync("AbortConflictMerge", taskId));
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId); => TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
public async Task CancelTaskAsync(string 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) public async Task<(bool Ok, string? Error)> DeleteTaskAsync(string taskId)
{ {
try try
{ {
await _hub.InvokeAsync("DeleteTask", taskId, CancellationToken.None); await InvokeTimedAsync("DeleteTask", () => _hub.InvokeAsync("DeleteTask", taskId, CancellationToken.None));
return (true, null); return (true, null);
} }
catch (HubException ex) catch (HubException ex)
@@ -354,7 +389,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task WakeQueueAsync() public async Task WakeQueueAsync()
{ {
await _hub.InvokeAsync("WakeQueue"); await InvokeTimedAsync("WakeQueue", () => _hub.InvokeAsync("WakeQueue"));
} }
public async Task<List<AgentInfo>> GetAgentsAsync() public async Task<List<AgentInfo>> GetAgentsAsync()
@@ -362,17 +397,17 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task RefreshAgentsAsync() public async Task RefreshAgentsAsync()
{ {
await _hub.InvokeAsync("RefreshAgents"); await InvokeTimedAsync("RefreshAgents", () => _hub.InvokeAsync("RefreshAgents"));
} }
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync() public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
=> await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents"); => await InvokeTimedAsync<SeedResultDto>("RestoreDefaultAgents", () => _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents"));
private async Task SeedActiveTasksAsync() private async Task SeedActiveTasksAsync()
{ {
try try
{ {
var active = await _hub.InvokeAsync<List<ActiveTaskDto>>("GetActive"); var active = await InvokeTimedAsync<List<ActiveTaskDto>>("GetActive", () => _hub.InvokeAsync<List<ActiveTaskDto>>("GetActive"));
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
ActiveTasks.Clear(); ActiveTasks.Clear();
@@ -402,18 +437,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task UpdateAppSettingsAsync(AppSettingsDto dto) public async Task UpdateAppSettingsAsync(AppSettingsDto dto)
{ {
await _hub.InvokeAsync("UpdateAppSettings", dto); await InvokeTimedAsync("UpdateAppSettings", () => _hub.InvokeAsync("UpdateAppSettings", dto));
} }
public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync() public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
=> await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>(); => await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>();
public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto) 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) public async Task DeletePrimeScheduleAsync(Guid id)
{ {
try { await _hub.InvokeAsync("DeletePrimeSchedule", id); } try { await InvokeTimedAsync("DeletePrimeSchedule", () => _hub.InvokeAsync("DeletePrimeSchedule", id)); }
catch { /* offline */ } catch { /* offline */ }
} }
@@ -423,27 +458,27 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
=> TryInvokeAsync<string>("GetWeekReport", IsoDay(start), IsoDay(end)); => TryInvokeAsync<string>("GetWeekReport", IsoDay(start), IsoDay(end));
public Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly 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() 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() public Task ClearMyDayAsync()
=> _hub.InvokeAsync("ClearMyDay"); => InvokeTimedAsync("ClearMyDay", () => _hub.InvokeAsync("ClearMyDay"));
public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day) public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day)
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>(); => await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>();
public async Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text) 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) 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) public async Task DeleteDailyNoteAsync(string id)
=> await _hub.InvokeAsync("DeleteDailyNote", id); => await InvokeTimedAsync("DeleteDailyNote", () => _hub.InvokeAsync("DeleteDailyNote", id));
public async Task<string> GetLastPrepLogAsync() public async Task<string> GetLastPrepLogAsync()
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty; => await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
@@ -456,12 +491,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task UpdateListAsync(UpdateListDto dto) public async Task UpdateListAsync(UpdateListDto dto)
{ {
await _hub.InvokeAsync("UpdateList", dto); await InvokeTimedAsync("UpdateList", () => _hub.InvokeAsync("UpdateList", dto));
} }
public async Task UpdateListConfigAsync(UpdateListConfigDto 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) public Task<ListConfigDto?> GetListConfigAsync(string listId)
@@ -469,37 +504,37 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto)
{ {
await _hub.InvokeAsync("UpdateTaskAgentSettings", dto); await InvokeTimedAsync("UpdateTaskAgentSettings", () => _hub.InvokeAsync("UpdateTaskAgentSettings", dto));
} }
public async Task<List<string>> GetRepoImportFoldersAsync() 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) public Task SetRepoImportFoldersAsync(List<string> folders)
=> _hub.InvokeAsync("SetRepoImportFolders", folders); => InvokeTimedAsync("SetRepoImportFolders", () => _hub.InvokeAsync("SetRepoImportFolders", folders));
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync() public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? []; => await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
public Task<List<string>> InstallSessionSkillAsync(string url) 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) public Task UpdateSessionSkillAsync(string sourceUrl)
=> _hub.InvokeAsync("UpdateSessionSkill", sourceUrl); => InvokeTimedAsync("UpdateSessionSkill", () => _hub.InvokeAsync("UpdateSessionSkill", sourceUrl));
public Task RemoveSessionSkillAsync(string 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) 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; return result?.BaseDirty;
} }
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
{ {
LastApproveTarget = 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) 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) 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) public async Task RejectReviewToIdleAsync(string taskId)
{ {
await _hub.InvokeAsync("RejectReviewToIdle", taskId); await InvokeTimedAsync("RejectReviewToIdle", () => _hub.InvokeAsync("RejectReviewToIdle", taskId));
} }
public async Task CancelReviewAsync(string 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) 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() 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) public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
=> await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId) => await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId)
@@ -534,7 +569,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
{ {
try 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); return (ok, null);
} }
catch (HubException ex) catch (HubException ex)
@@ -548,63 +583,63 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
} }
public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId) 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) 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) 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) 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) 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) 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) 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( public async Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) 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( public async Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default) 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) 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) 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) 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) 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) 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) public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
=> await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? []; => await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? [];
public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) 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) public async Task ContinuePlanningMergeAsync(string planningTaskId)
{ {
await _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId); await InvokeTimedAsync("ContinuePlanningMerge", () => _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId));
} }
public async Task AbortPlanningMergeAsync(string 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() 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) 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() public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync()
=> TryInvokeAsync<OnlineInboxStateDto>("GetOnlineInboxState"); => TryInvokeAsync<OnlineInboxStateDto>("GetOnlineInboxState");
public async Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) public async Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input)
=> await _hub.InvokeAsync("SetOnlineInboxConfig", input); => await InvokeTimedAsync("SetOnlineInboxConfig", () => _hub.InvokeAsync("SetOnlineInboxConfig", input));
public async Task SetOnlineInboxAuthAsync(string refreshToken) public async Task SetOnlineInboxAuthAsync(string refreshToken)
=> await _hub.InvokeAsync("SetOnlineInboxAuth", refreshToken); => await InvokeTimedAsync("SetOnlineInboxAuth", () => _hub.InvokeAsync("SetOnlineInboxAuth", refreshToken));
public async Task ClearOnlineInboxAuthAsync() public async Task ClearOnlineInboxAuthAsync()
=> await _hub.InvokeAsync("ClearOnlineInboxAuth"); => await InvokeTimedAsync("ClearOnlineInboxAuth", () => _hub.InvokeAsync("ClearOnlineInboxAuth"));
public Task<UsageSnapshotDto?> GetUsageSnapshotAsync() public Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
=> TryInvokeAsync<UsageSnapshotDto>("GetUsageSnapshot"); => 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) private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct)
{ {
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try try
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
@@ -701,12 +703,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
} }
} }
await Merge.RefreshMergePreviewAsync(); await Merge.RefreshMergePreviewAsync();
ok = true;
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
finally { OperationTiming.Shared.Record("db", "DetailsIsland.BindAsync", sw.Elapsed, ok); }
} }
private async System.Threading.Tasks.Task LoadChildOutcomesAsync(string parentTaskId, CancellationToken ct) private async System.Threading.Tasks.Task LoadChildOutcomesAsync(string parentTaskId, CancellationToken ct)
{ {
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try try
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); 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) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
ok = true;
if (children.Count == 0) return; if (children.Count == 0) return;
ChildOutcomes.Clear(); ChildOutcomes.Clear();
@@ -749,10 +756,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
catch { /* best-effort */ } catch { /* best-effort */ }
finally { OperationTiming.Shared.Record("db", "DetailsIsland.LoadChildOutcomesAsync", sw.Elapsed, ok); }
} }
private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct) private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
{ {
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try try
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
@@ -762,6 +772,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
.Where(t => t.ParentTaskId == parentTaskId) .Where(t => t.ParentTaskId == parentTaskId)
.ToListAsync(ct); .ToListAsync(ct);
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
ok = true;
foreach (var child in children) foreach (var child in children)
{ {
@@ -791,6 +802,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
catch { /* best-effort */ } catch { /* best-effort */ }
finally { OperationTiming.Shared.Record("db", "DetailsIsland.LoadPlanningChildrenAsync", sw.Elapsed, ok); }
} }
private async System.Threading.Tasks.Task RefreshPlanningChildAsync(string childTaskId) 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); } foreach (var s in smart) { Items.Add(s); SmartLists.Add(s); }
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var sw = System.Diagnostics.Stopwatch.StartNew();
var lists = new ListRepository(ctx); var ok = false;
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" }); try
var dotColors = new[] { "Moss", "Peat", "Sage" };
int idx = 0;
foreach (var l in await lists.GetAllAsync(ct))
{ {
if (seedNames.Contains(l.Name)) continue; await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var item = new ListNavItemViewModel 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}", if (seedNames.Contains(l.Name)) continue;
Name = l.Name, var item = new ListNavItemViewModel
Kind = ListKind.User, {
IconKey = "Folder", Id = $"user:{l.Id}",
DotColorKey = dotColors[idx % dotColors.Length], Name = l.Name,
WorkingDir = l.WorkingDir, Kind = ListKind.User,
DefaultCommitType = l.DefaultCommitType, IconKey = "Folder",
IsManual = l.IsManual, DotColorKey = dotColors[idx % dotColors.Length],
FindingsTracked = l.FindingsTracked, WorkingDir = l.WorkingDir,
}; DefaultCommitType = l.DefaultCommitType,
Items.Add(item); IsManual = l.IsManual,
UserLists.Add(item); FindingsTracked = l.FindingsTracked,
idx++; };
Items.Add(item);
UserLists.Add(item);
idx++;
}
ok = true;
} }
finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); }
await RefreshCountsAsync(ct); await RefreshCountsAsync(ct);
SelectedList = Items.FirstOrDefault(); SelectedList = Items.FirstOrDefault();
@@ -286,6 +293,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
public async Task RefreshCountsAsync(CancellationToken ct = default) public async Task RefreshCountsAsync(CancellationToken ct = default)
{ {
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try try
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); 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); var filter = _filters.Resolve(item.Id);
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount); item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
} }
ok = true;
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch { /* best-effort refresh */ } catch { /* best-effort refresh */ }
finally { OperationTiming.Shared.Record("db", "ListsIsland.RefreshCountsAsync", sw.Elapsed, ok); }
} }
[RelayCommand] [RelayCommand]
@@ -382,9 +393,16 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
MoveWithinCollection(UserLists, source, target, placeBelow); MoveWithinCollection(UserLists, source, target, placeBelow);
var orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList(); var orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList();
await using var ctx = await _dbFactory.CreateDbContextAsync(); var sw = System.Diagnostics.Stopwatch.StartNew();
var lists = new ListRepository(ctx); var ok = false;
await lists.ReorderAsync(orderedIds); 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( private static void MoveWithinCollection(
@@ -360,6 +360,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private async Task LoadForListAsync( private async Task LoadForListAsync(
ListNavItemViewModel list, CancellationToken ct, Dictionary<string, TaskRowViewModel>? reusable) ListNavItemViewModel list, CancellationToken ct, Dictionary<string, TaskRowViewModel>? reusable)
{ {
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try try
{ {
await using var db = await _dbFactory.CreateDbContextAsync(ct); await using var db = await _dbFactory.CreateDbContextAsync(ct);
@@ -460,8 +462,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
Regroup(); Regroup();
UpdateSubtitle(); UpdateSubtitle();
ok = true;
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
finally { OperationTiming.Shared.Record("db", "TasksIsland.LoadForListAsync", sw.Elapsed, ok); }
} }
internal void Regroup() internal void Regroup()
@@ -816,17 +820,24 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
var listId = _currentList.Id["user:".Length..]; var listId = _currentList.Id["user:".Length..];
var orderedIds = Items.Select(i => i.Id).ToList(); var orderedIds = Items.Select(i => i.Id).ToList();
await using var db = await _dbFactory.CreateDbContextAsync(); var sw = System.Diagnostics.Stopwatch.StartNew();
var idSet = orderedIds.ToHashSet(); var ok = false;
var entities = await db.Tasks try
.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]); await using var db = await _dbFactory.CreateDbContextAsync();
if (e is not null) e.SortOrder = i; 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>( private static void MoveWithinCollection<T>(
@@ -990,17 +1001,22 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
if (!ok) return; if (!ok) return;
} }
await using var db = await _dbFactory.CreateDbContextAsync(); var sw = System.Diagnostics.Stopwatch.StartNew();
var repo = new TaskRepository(db); try
foreach (var row in toDelete)
{ {
try await using var db = await _dbFactory.CreateDbContextAsync();
var repo = new TaskRepository(db);
foreach (var row in toDelete)
{ {
await repo.DeleteAsync(row.Id); try
Items.Remove(row); {
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(); Regroup();
UpdateSubtitle(); UpdateSubtitle();
@@ -1476,6 +1492,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
} }
List<TaskEntity> entities; List<TaskEntity> entities;
var sw = System.Diagnostics.Stopwatch.StartNew();
var dbOk = false;
try try
{ {
var idSet = ids.ToHashSet(); var idSet = ids.ToHashSet();
@@ -1485,6 +1503,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
.Include(t => t.Worktree) .Include(t => t.Worktree)
.Where(t => idSet.Contains(t.Id)) .Where(t => idSet.Contains(t.Id))
.ToListAsync(ct); .ToListAsync(ct);
dbOk = true;
} }
catch (OperationCanceledException) { return; } catch (OperationCanceledException) { return; }
catch (Exception ex) catch (Exception ex)
@@ -1492,6 +1511,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
System.Diagnostics.Debug.WriteLine($"TasksIsland: reconcile tick failed ({ex.Message})"); System.Diagnostics.Debug.WriteLine($"TasksIsland: reconcile tick failed ({ex.Message})");
return; return;
} }
finally { OperationTiming.Shared.Record("db", "TasksIsland.ReconcileTickAsync", sw.Elapsed, dbOk); }
if (ReconcileTickTestBarrier is { } barrier) await barrier(); 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);
}
}
}