Files
ClaudeDo/docs/superpowers/plans/2026-06-04-prep-log-persistence.md
mika kuns 26758b6e8a docs(daily-prep): add prep-log persistence plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 09:46:42 +02:00

9.9 KiB

Persist Daily-Prep Log Across Restarts — Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use - [ ].

Goal: The prep log currently lives only in memory (DetailsIslandViewModel.PrepLog), so after an app restart the prep terminal is empty. Persist the last prep run's output to a file in the worker and load it into the prep terminal when opened.

Root cause (confirmed): PrimeRunner.FireAsync streams stdout lines via _broadcaster.PrepLineAsync(line) only — it writes no file and stores no record. PrepLog is an in-memory ObservableCollection populated only by live PrepLine events. Nothing persists → empty after restart.

Approach: Worker writes each streamed line to <appdata>/logs/daily-prep.log (truncated at run start = last run only) using the existing LogWriter. A new hub method GetLastPrepLog() returns the file (tail-capped, like get_task_log). The UI loads it into PrepLog when the prep view opens, but only when PrepLog is empty and no run is in progress.

Tech: ASP.NET Core SignalR, Avalonia + CommunityToolkit.Mvvm, xUnit.

Build/test

dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release

GUI not headlessly verifiable — note it; human verifies visuals.

Shared constant

The prep-log path must be identical in PrimeRunner (writer) and WorkerHub (reader). Define it once and reference from both: Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "logs", "daily-prep.log"). Add a small static helper so both sides agree, e.g. in src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs (already the prep "home"):

public static string LogPath() =>
    System.IO.Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "logs", "daily-prep.log");

Task 1: Worker — write the prep log + serve it

Files:

  • Modify: src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs (add LogPath() helper)

  • Modify: src/ClaudeDo.Worker/Prime/PrimeRunner.cs

  • Modify: src/ClaudeDo.Worker/Hub/WorkerHub.cs

  • Test: tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs

  • Step 1: Add DailyPrepPrompt.LogPath() (code above).

  • Step 2: Write the failing test. Extend the existing streaming test (or add one) asserting that after FireAsync with emitted stdout lines, the file at DailyPrepPrompt.LogPath() contains those lines, and that a prior run's content is replaced (truncate-on-start). Since the path is the real app-data logs dir, the test should delete the file first and clean up after; assert exact line content.

[Fact]
public async Task FireAsync_writes_last_run_to_prep_log_file()
{
    var path = DailyPrepPrompt.LogPath();
    if (File.Exists(path)) File.Delete(path);

    var claude = new FakeClaudeProcess(emitLines: new[] { "lineA", "lineB" }, exitCode: 0, result: "ok");
    var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
    await runner.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None);

    var contents = await File.ReadAllTextAsync(path);
    Assert.Contains("lineA", contents);
    Assert.Contains("lineB", contents);

    // Truncation: a second run with different lines replaces the file.
    var claude2 = new FakeClaudeProcess(emitLines: new[] { "lineC" }, exitCode: 0, result: "ok");
    var runner2 = NewRunner(claude2, new RecordingPrimeBroadcaster());
    await runner2.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None);
    var after = await File.ReadAllTextAsync(path);
    Assert.DoesNotContain("lineA", after);
    Assert.Contains("lineC", after);
}
  • Step 3: Run — expect FAIL.

  • Step 4: Write the file in PrimeRunner.FireAsync. After the gate is acquired and before RunAsync: compute var logPath = DailyPrepPrompt.LogPath();, delete it if present (truncate → last run only), then create await using var logWriter = new LogWriter(logPath);. Change the stream callback to write AND broadcast:

            var logPath = DailyPrepPrompt.LogPath();
            try { if (File.Exists(logPath)) File.Delete(logPath); } catch { /* best effort */ }
            await using var logWriter = new LogWriter(logPath);

            await _broadcaster.PrepStartedAsync();
            // ... build prompt/args/timeoutCts ...
            var result = await _claude.RunAsync(
                arguments: args, prompt: prompt, workingDirectory: cwd,
                onStdoutLine: async line =>
                {
                    await logWriter.WriteLineAsync(line);
                    await _broadcaster.PrepLineAsync(line);
                },
                ct: timeoutCts.Token);

Keep the existing success/finally/PrepFinishedAsync/gate logic. using ClaudeDo.Worker.Runner; is already present (LogWriter lives there). The await using LogWriter disposes (flushes) before the method returns.

  • Step 5: Run — expect PASS. Build the Worker.

  • Step 6: Add WorkerHub.GetLastPrepLog() (no ctor change — reads the static path):

public Task<string> GetLastPrepLog()
{
    var path = DailyPrepPrompt.LogPath();
    if (!File.Exists(path)) return Task.FromResult(string.Empty);

    const int maxBytes = 256 * 1024;
    var bytes = File.ReadAllBytes(path);
    var text = bytes.Length <= maxBytes
        ? System.Text.Encoding.UTF8.GetString(bytes)
        : System.Text.Encoding.UTF8.GetString(bytes, bytes.Length - maxBytes, maxBytes);
    return Task.FromResult(text);
}

Add using ClaudeDo.Worker.Prime; to WorkerHub.cs if not present.

  • Step 7: Build Worker; run the full Worker.Tests project.
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
  • Step 8: Commit (stage only Task 1 files):
git commit -m "feat(daily-prep): persist last prep run to a log file and serve it via GetLastPrepLog"

Task 2: UI — load the persisted prep log when opening

Files:

  • Modify: src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs

  • Modify: src/ClaudeDo.Ui/Services/WorkerClient.cs

  • Modify: src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs

  • Modify fakes: tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs, tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs (FakeWorkerClient)

  • Test: tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPrepModeTests.cs

  • Step 1: Declare on IWorkerClient: Task<string> GetLastPrepLogAsync();

  • Step 2: Implement in WorkerClient: public Task<string> GetLastPrepLogAsync() => _hub.InvokeAsync<string>("GetLastPrepLog"); (match neighbouring call style; if there is a TryInvokeAsync helper for resilience, mirror GetWeekReportAsync and return ?? string.Empty).

  • Step 3: Update fakes. Add public Task<string> GetLastPrepLogAsync() => Task.FromResult(string.Empty); to both fakes. In StubWorkerClient, make it return a settable backing field, e.g. public string LastPrepLog = ""; public Task<string> GetLastPrepLogAsync() => Task.FromResult(LastPrepLog);.

  • Step 4: Write the failing test.

[Fact]
public async Task ShowPrep_loads_persisted_log_when_empty()
{
    var stub = new StubWorkerClient { LastPrepLog = "{\"type\":\"assistant\",\"text\":\"restored\"}" };
    var vm = NewDetailsVm(stub);

    vm.ShowPrep();
    await Task.Delay(50); // allow the async load to run; or expose the load task to await deterministically

    Assert.NotEmpty(vm.PrepLog);
}

Prefer determinism over Task.Delay: have ShowPrep start the load and expose the in-flight Task (e.g. a LoadLastPrepLogAsync() method the test can call/await directly), then assert. Use whichever the existing test style favors.

  • Step 5: Implement load in DetailsIslandViewModel. Add a method and call it from ShowPrep:
public void ShowPrep()
{
    Bind(null);
    IsNotesMode = false;
    IsPrepMode = true;
    _ = LoadLastPrepLogIfEmptyAsync();
}

private async Task LoadLastPrepLogIfEmptyAsync()
{
    if (_worker is null || IsPrepRunning || PrepLog.Count > 0) return;
    string text;
    try { text = await _worker.GetLastPrepLogAsync(); }
    catch { return; }
    if (IsPrepRunning || PrepLog.Count > 0) return; // a live run may have started meanwhile
    foreach (var line in text.Split('\n'))
    {
        var trimmed = line.TrimEnd('\r');
        if (trimmed.Length > 0) AppendStdoutLine(PrepLog, trimmed);
    }
}

This reuses the existing AppendStdoutLine(PrepLog, line) formatter path, so persisted NDJSON renders identically to the live stream. The guards ensure it never overwrites a live run (PrepStarted clears PrepLog and sets IsPrepRunning) or an already-loaded log.

  • Step 6: Build App + run UI tests.
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
  • Step 7: Manual smoke (human): run a prep, restart the app, open the prep log on MyDay → the last run's output is shown.

  • Step 8: Commit (stage only Task 2 files):

git commit -m "feat(daily-prep): load persisted prep log into the terminal on open"

Notes / risks

  • PrimeRunner writes via the same LogWriter pattern TaskRunner uses; concurrency behavior matches existing code (no new locking introduced).
  • Path is shared via DailyPrepPrompt.LogPath() so writer and reader never diverge.
  • Load is guarded (PrepLog empty && !IsPrepRunning) to avoid clobbering a live stream — the order of ShowPrep's flag set vs. the async load matters; re-check the guard after the await.
  • Last run only (file truncated each run); history is out of scope.