209 lines
9.9 KiB
Markdown
209 lines
9.9 KiB
Markdown
# 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
|
|
```bash
|
|
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"):
|
|
```csharp
|
|
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.
|
|
|
|
```csharp
|
|
[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:
|
|
|
|
```csharp
|
|
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):
|
|
|
|
```csharp
|
|
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.**
|
|
|
|
```bash
|
|
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):
|
|
```bash
|
|
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.**
|
|
|
|
```csharp
|
|
[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`:
|
|
|
|
```csharp
|
|
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.**
|
|
|
|
```bash
|
|
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):
|
|
```bash
|
|
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.
|