refactor(claude-do): merge OperationTiming hinter Kill-Switch, Default aus (vor dem näc

ClaudeDo-Task: ae170c8b-33cf-4cf3-932e-865fbdd2afa6
This commit is contained in:
Mika Kuns
2026-08-17 08:52:57 +02:00
3 changed files with 34 additions and 3 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ new editor boilerplate there rather than copying it a third time.
- **IPrimeScheduleApi** — prime-schedule CRUD. - **IPrimeScheduleApi** — prime-schedule CRUD.
- **UpdateCheckService** — polls releases; `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` feed the shell's update banner. - **UpdateCheckService** — polls releases; `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` feed the shell's update banner.
- **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges. - **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges.
- **OperationTiming** — NDJSON sink (`~/.todo-app/logs/operation-timing.ndjson`, one rolled `.1` at 4 MB) behind every hub invoke and bulk DB path; each line carries `pid` because app restarts interleave in one file. Successful calls under 25 ms are dropped, **failed/cancelled ones always land** — a 1 ms cancelled `BindAsync` is churn signal, not noise. `DetailsIsland.BindAsync:<source>` carries the selection trigger (`TasksIslandViewModel.SelectionSource`, set via `SelectFrom` — never assign `SelectedTask` directly). `Shared` is a settable static singleton **on purpose**`Ui.Tests`/`Worker.Tests` each carry a `TestSetup` module initializer that redirects it to a temp file before any test runs, because both projects construct real ViewModels (`DetailsIslandViewModel`, `TasksIslandViewModel`) that call `Shared.Record` directly; without the redirect a single test run appends 50-100 lines straight into the live app's log (a day of ~1900 `BindAsync` lines, 91-96% cancelled, turned out to be 13 such test runs plus pre-fix-binary artifacts — real user-driven binds that day: 4). - **OperationTiming** — NDJSON sink (`~/.todo-app/logs/operation-timing.ndjson`, one rolled `.1` at 4 MB) behind every hub invoke and bulk DB path; each line carries `pid` because app restarts interleave in one file. Standardmäßig aus — `Shared` schreibt nur, wenn die Umgebungsvariable `CLAUDEDO_OP_TIMING=1` beim Prozessstart gesetzt ist (einmal statisch gelesen, kein Overhead pro Call). Successful calls under 25 ms are dropped, **failed/cancelled ones always land** — a 1 ms cancelled `BindAsync` is churn signal, not noise. `DetailsIsland.BindAsync:<source>` carries the selection trigger (`TasksIslandViewModel.SelectionSource`, set via `SelectFrom` — never assign `SelectedTask` directly). `Shared` is a settable static singleton **on purpose**`Ui.Tests`/`Worker.Tests` each carry a `TestSetup` module initializer that redirects it to a temp file before any test runs (still writing — the kill switch only gates `Shared`'s default, not the constructor), because both projects construct real ViewModels (`DetailsIslandViewModel`, `TasksIslandViewModel`) that call `Shared.Record` directly; without the redirect a single test run appends 50-100 lines straight into the live app's log (a day of ~1900 `BindAsync` lines, 91-96% cancelled, turned out to be 13 such test runs plus pre-fix-binary artifacts — real user-driven binds that day: 4).
- **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**. - **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**.
## Converters ## Converters
+12 -2
View File
@@ -7,6 +7,7 @@ namespace ClaudeDo.Ui.Services;
/// Appends one NDJSON line per timed operation (hub invoke, bulk DB read/write) to a log file, so /// 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. /// 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. /// Never throws — a failed write is swallowed, because a measurement must never disturb the app.
/// <see cref="Shared"/> is off by default; set <c>CLAUDEDO_OP_TIMING=1</c> to turn it back on.
/// </summary> /// </summary>
public sealed class OperationTiming public sealed class OperationTiming
{ {
@@ -21,29 +22,38 @@ public sealed class OperationTiming
public static string DefaultPath => public static string DefaultPath =>
Path.Combine(Paths.AppDataRoot(), "logs", "operation-timing.ndjson"); Path.Combine(Paths.AppDataRoot(), "logs", "operation-timing.ndjson");
/// <summary>Kill switch for <see cref="Shared"/>: off by default, set
/// <c>CLAUDEDO_OP_TIMING=1</c> in the environment to turn the sink back on.</summary>
private static readonly bool DefaultEnabled =
Environment.GetEnvironmentVariable("CLAUDEDO_OP_TIMING") == "1";
// Settable, not just get-only: xUnit constructs real ViewModels (DetailsIslandViewModel, // Settable, not just get-only: xUnit constructs real ViewModels (DetailsIslandViewModel,
// TasksIslandViewModel, ...) that call Shared.Record directly, so without a way to redirect // TasksIslandViewModel, ...) that call Shared.Record directly, so without a way to redirect
// it every test run appended its churn straight into the live app's NDJSON file at the real // it every test run appended its churn straight into the live app's NDJSON file at the real
// AppData path — see Ui.Tests' TestSetup module initializer, which points this at a temp file // AppData path — see Ui.Tests' TestSetup module initializer, which points this at a temp file
// for the whole test process before any test runs. // for the whole test process before any test runs.
public static OperationTiming Shared { get; set; } = new(DefaultPath); public static OperationTiming Shared { get; set; } = new(DefaultPath, enabled: DefaultEnabled);
private static readonly int ProcessId = Environment.ProcessId; private static readonly int ProcessId = Environment.ProcessId;
private readonly string _path; private readonly string _path;
private readonly int _minMs; private readonly int _minMs;
private readonly long _maxBytes; private readonly long _maxBytes;
private readonly bool _enabled;
private readonly object _writeLock = new(); private readonly object _writeLock = new();
public OperationTiming(string filePath, int minMs = DefaultMinMs, long maxBytes = DefaultMaxBytes) public OperationTiming(string filePath, int minMs = DefaultMinMs, long maxBytes = DefaultMaxBytes, bool enabled = true)
{ {
_path = filePath; _path = filePath;
_minMs = minMs; _minMs = minMs;
_maxBytes = maxBytes; _maxBytes = maxBytes;
_enabled = enabled;
} }
public void Record(string kind, string operation, TimeSpan elapsed, bool ok) public void Record(string kind, string operation, TimeSpan elapsed, bool ok)
{ {
if (!_enabled) return;
try try
{ {
var ms = (long)elapsed.TotalMilliseconds; var ms = (long)elapsed.TotalMilliseconds;
@@ -96,6 +96,27 @@ public class OperationTimingTests
} }
} }
[Fact]
public void Record_DoesNothingWhenDisabled()
{
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, enabled: false);
try
{
var exception = Record.Exception(() => sink.Record("hub", "RunNow", TimeSpan.FromMilliseconds(1), ok: false));
Assert.Null(exception);
Assert.False(File.Exists(path));
Assert.False(Directory.Exists(dir));
}
finally
{
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact] [Fact]
public void Record_SwallowsWriteFailureInsteadOfThrowing() public void Record_SwallowsWriteFailureInsteadOfThrowing()
{ {