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:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user