journal-bot/docs/superpowers/plans/2026-06-16-service-tray-gui.md

1181 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Windows Service + Tray-GUI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a .NET Windows Service that runs `journal_bot ingest` every 15 minutes, and a WPF tray GUI that shows history/status/logs and triggers ingest/process/both manually.
**Architecture:** Two thin .NET shells (`tools/JournalBot.Service`, `tools/JournalBot.Tray`) wrap the existing Python CLI. They communicate only via CLI subprocess calls and the `runtime/` filesystem. The Python `QueueItem` is extended with optional result fields so the GUI can show what was written where. Hybrid split: the service does only `ingest` (no LM Studio needed, runs as LocalSystem); the GUI does `process` (needs LM Studio, runs as user).
**Tech Stack:** Python 3.12 (pydantic, pytest), .NET 10 (Worker Service, WPF, `Hardcodet.NotifyIcon.Wpf`, xUnit). .NET SDK 10.0.103 confirmed present.
---
## Conventions
- **Python venv:** always `.venv/Scripts/python.exe` (uv not on PATH).
- **Encoding:** all file I/O `encoding="utf-8"`. Never PowerShell `Set-Content`/`Get-Content` without `-Encoding UTF8`.
- **Commits:** conventional, English, imperative. Commit per task.
- **Paths:** project root `D:\projects\chrka\journal-bot`. .NET projects under `tools/`.
---
## Phase A — Python: extended history fields (TDD)
### Task 1: Add result fields to QueueItem
**Files:**
- Modify: `src/journal_bot/queue.py:6-14` (QueueItem model)
- Test: `tests/test_queue.py`
- [ ] **Step 1: Write the failing test**
Add to `tests/test_queue.py`:
```python
def test_queue_item_has_optional_result_fields():
item = QueueItem(
update_id=1,
received_at="2026-06-14T14:32:17+02:00",
type="text",
text="Hello",
)
assert item.target_path is None
assert item.written_entry is None
assert item.processed_at is None
```
- [ ] **Step 2: Run test to verify it fails**
Run: `.venv/Scripts/python.exe -m pytest tests/test_queue.py::test_queue_item_has_optional_result_fields -v`
Expected: FAIL with `AttributeError` (no attribute `target_path`).
- [ ] **Step 3: Add fields to the model**
In `src/journal_bot/queue.py`, extend `QueueItem` (after `attempts`):
```python
class QueueItem(BaseModel):
update_id: int
received_at: str # ISO 8601
type: str # "text" | "voice" | "photo"
text: str = ""
raw_audio_path: Optional[str] = None
image_embed: Optional[str] = None
image_caption: Optional[str] = None
attempts: int = 0
# Result fields, filled by process after a successful write:
target_path: Optional[str] = None # e.g. "05 Daily Notes/2026-06-15.md"
written_entry: Optional[str] = None # the markdown entry actually written
processed_at: Optional[str] = None # ISO 8601
```
- [ ] **Step 4: Run test to verify it passes**
Run: `.venv/Scripts/python.exe -m pytest tests/test_queue.py::test_queue_item_has_optional_result_fields -v`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/journal_bot/queue.py tests/test_queue.py
git commit -m "feat(queue): add optional result fields to QueueItem"
```
---
### Task 2: Persist updated item JSON in complete()
`complete()` currently only renames working→done, which would discard the new result fields. Rewrite it like `fail()`: write the (possibly mutated) item JSON atomically into `done/`, then unlink the working source.
**Files:**
- Modify: `src/journal_bot/queue.py:49-52` (`complete` method)
- Test: `tests/test_queue.py`
- [ ] **Step 1: Write the failing test**
Add to `tests/test_queue.py`:
```python
def test_complete_persists_result_fields(queue, tmp_path):
queue.enqueue(make_item(1))
item = queue.claim_next()
item.target_path = "05 Daily Notes/2026-06-14.md"
item.written_entry = "## 14:32\nHallo"
item.processed_at = "2026-06-14T15:00:00+02:00"
queue.complete(item)
done_path = tmp_path / "done" / "1.json"
assert done_path.exists()
assert not (tmp_path / "working" / "1.json").exists()
data = json.loads(done_path.read_text(encoding="utf-8"))
assert data["target_path"] == "05 Daily Notes/2026-06-14.md"
assert data["written_entry"] == "## 14:32\nHallo"
assert data["processed_at"] == "2026-06-14T15:00:00+02:00"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `.venv/Scripts/python.exe -m pytest tests/test_queue.py::test_complete_persists_result_fields -v`
Expected: FAIL — `data["target_path"]` is `None` (old `complete` did a plain rename, losing nothing but also persisting the pre-mutation file; actually the claimed file on disk still has null fields). Confirm it fails.
- [ ] **Step 3: Rewrite `complete`**
Replace `complete` in `src/journal_bot/queue.py`:
```python
def complete(self, item: QueueItem) -> None:
src = self._path(self.working, item.update_id)
dst = self._path(self.done, item.update_id)
# Mirror fail(): write the (mutated) item atomically into done/,
# then remove the working source. Preserves crash-safety and
# persists result fields set by the processor.
tmp = dst.with_suffix(".tmp")
tmp.write_text(item.model_dump_json(), encoding="utf-8")
tmp.replace(dst)
if src.exists():
src.unlink()
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `.venv/Scripts/python.exe -m pytest tests/test_queue.py -v`
Expected: PASS (new test + existing `test_complete_moves_to_done`).
- [ ] **Step 5: Commit**
```bash
git add src/journal_bot/queue.py tests/test_queue.py
git commit -m "feat(queue): persist mutated item JSON in complete()"
```
---
### Task 3: Fill result fields in process_once
`process_once` gets a new `processed_at: str` parameter (ISO 8601), passed in like `today` for testability (no hidden `datetime.now()`). It sets the three fields on the item before `queue.complete(item)`.
**Files:**
- Modify: `src/journal_bot/process.py:9-42` (signature + body)
- Modify: `src/journal_bot/__main__.py:40-50` (`cmd_process` passes `processed_at`)
- Test: `tests/test_process.py`
- [ ] **Step 1: Write the failing test**
Add to `tests/test_process.py` (top: `import json`):
```python
def test_process_records_result_fields(tmp_path):
vault = tmp_path / "vault"
(vault / "05 Daily Notes").mkdir(parents=True)
queue = Queue(tmp_path / "q")
queue.enqueue(make_item(1))
processor = FakeProcessor(ProcessorOutput(
target_date="2026-06-14",
target_path="05 Daily Notes/2026-06-14.md",
entry_markdown="## 14:32\nHallo",
))
writer = VaultWriter(vault)
process_once(
processor, queue, writer,
vault_path=vault, today=date(2026, 6, 14),
processed_at="2026-06-14T15:00:00+02:00",
)
data = json.loads(
(tmp_path / "q" / "done" / "1.json").read_text(encoding="utf-8")
)
assert data["target_path"] == "05 Daily Notes/2026-06-14.md"
assert data["written_entry"] == "## 14:32\nHallo"
assert data["processed_at"] == "2026-06-14T15:00:00+02:00"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `.venv/Scripts/python.exe -m pytest tests/test_process.py::test_process_records_result_fields -v`
Expected: FAIL — `process_once()` got an unexpected keyword argument `processed_at`.
- [ ] **Step 3: Update `process_once` signature and body**
In `src/journal_bot/process.py`, change the signature and set fields before `complete`:
```python
def process_once(processor, queue: Queue, writer: VaultWriter, vault_path: Path, today: date, processed_at: str) -> int:
```
Inside the `try`, replace the block from `writer.append(...)` through `queue.complete(item)` with:
```python
writer.append(output.target_path, entry, clarifications=output.clarifications)
item.target_path = output.target_path
item.written_entry = entry
item.processed_at = processed_at
queue.complete(item)
processed += 1
```
- [ ] **Step 4: Update the only other caller (`cmd_process`)**
In `src/journal_bot/__main__.py`, `cmd_process`, add the import and pass `processed_at`:
At top of file (with other imports): `from datetime import date, datetime`
(`date` is already imported; change the existing `from datetime import date` line to include `datetime`.)
Change the `process_once(...)` call in `cmd_process`:
```python
n = process_once(
processor, queue, writer,
vault_path=cfg.vault_path,
today=date.today(),
processed_at=datetime.now().astimezone().isoformat(),
)
```
- [ ] **Step 5: Run the full Python suite**
Run: `.venv/Scripts/python.exe -m pytest -v`
Expected: all PASS. (Existing `test_process_*` calls without `processed_at` — check: `test_process_writes_to_vault_and_completes`, `test_process_skips_when_unhealthy`, `test_process_fails_item_on_error` call `process_once` without the new arg → they will FAIL with missing positional arg.)
- [ ] **Step 6: Fix the three existing process tests**
In `tests/test_process.py`, add `processed_at="2026-06-14T15:00:00+02:00"` to the `process_once(...)` call in each of:
- `test_process_writes_to_vault_and_completes`
- `test_process_skips_when_unhealthy`
- `test_process_fails_item_on_error`
- [ ] **Step 7: Run the full Python suite again**
Run: `.venv/Scripts/python.exe -m pytest -v`
Expected: all PASS.
- [ ] **Step 8: Commit**
```bash
git add src/journal_bot/process.py src/journal_bot/__main__.py tests/test_process.py
git commit -m "feat(process): record target_path, written_entry, processed_at on done items"
```
---
## Phase B — .NET solution scaffold
### Task 4: Create solution and shared QueueItem DTO
**Files:**
- Create: `tools/JournalBot.sln`
- Create: `tools/JournalBot.Shared/JournalBot.Shared.csproj`
- Create: `tools/JournalBot.Shared/QueueItem.cs`
- [ ] **Step 1: Scaffold the solution and shared lib**
Run (from project root):
```bash
dotnet new sln -o tools -n JournalBot
dotnet new classlib -o tools/JournalBot.Shared -n JournalBot.Shared
dotnet sln tools/JournalBot.sln add tools/JournalBot.Shared/JournalBot.Shared.csproj
```
Delete the generated `tools/JournalBot.Shared/Class1.cs`.
- [ ] **Step 2: Write the QueueItem DTO**
Create `tools/JournalBot.Shared/QueueItem.cs` — mirrors the Python pydantic model. `snake_case` JSON, so use `[JsonPropertyName]`:
```csharp
using System.Text.Json.Serialization;
namespace JournalBot.Shared;
public sealed class QueueItem
{
[JsonPropertyName("update_id")] public long UpdateId { get; set; }
[JsonPropertyName("received_at")] public string ReceivedAt { get; set; } = "";
[JsonPropertyName("type")] public string Type { get; set; } = "";
[JsonPropertyName("text")] public string Text { get; set; } = "";
[JsonPropertyName("raw_audio_path")] public string? RawAudioPath { get; set; }
[JsonPropertyName("image_embed")] public string? ImageEmbed { get; set; }
[JsonPropertyName("image_caption")] public string? ImageCaption { get; set; }
[JsonPropertyName("attempts")] public int Attempts { get; set; }
[JsonPropertyName("target_path")] public string? TargetPath { get; set; }
[JsonPropertyName("written_entry")] public string? WrittenEntry { get; set; }
[JsonPropertyName("processed_at")] public string? ProcessedAt { get; set; }
}
```
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build tools/JournalBot.Shared/JournalBot.Shared.csproj`
Expected: Build succeeded.
- [ ] **Step 4: Commit**
```bash
git add tools/JournalBot.sln tools/JournalBot.Shared
git commit -m "feat(tools): scaffold .NET solution with shared QueueItem DTO"
```
---
## Phase C — .NET: BotRunner + RuntimeReader (with xUnit tests)
### Task 5: BotRunner (subprocess wrapper)
`BotRunner` runs `python.exe -m journal_bot <cmd>` in a working dir, captures stdout/stderr, returns exit code + combined output. Used by both Service and Tray.
**Files:**
- Create: `tools/JournalBot.Shared/BotRunner.cs`
- Create: `tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj`
- Create: `tools/JournalBot.Shared.Tests/BotRunnerTests.cs`
- [ ] **Step 1: Scaffold the test project**
```bash
dotnet new xunit -o tools/JournalBot.Shared.Tests -n JournalBot.Shared.Tests
dotnet sln tools/JournalBot.sln add tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj
dotnet add tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj reference tools/JournalBot.Shared/JournalBot.Shared.csproj
```
Delete generated `tools/JournalBot.Shared.Tests/UnitTest1.cs`.
- [ ] **Step 2: Write the failing test**
`BotRunner` is testable by running a trivial cross-platform program. Use the `python` executable itself with `-c` to avoid depending on the bot. Create `tools/JournalBot.Shared.Tests/BotRunnerTests.cs`:
```csharp
using JournalBot.Shared;
using Xunit;
public class BotRunnerTests
{
// Locate the venv python; skip if absent (CI without venv).
private static string? VenvPython()
{
var root = FindRepoRoot();
if (root is null) return null;
var py = Path.Combine(root, ".venv", "Scripts", "python.exe");
return File.Exists(py) ? py : null;
}
private static string? FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "pyproject.toml")))
dir = dir.Parent;
return dir?.FullName;
}
[Fact]
public async Task Run_capturesStdoutAndExitCode()
{
var py = VenvPython();
if (py is null) return; // environment without venv; nothing to assert
var runner = new BotRunner(py, FindRepoRoot()!);
var result = await runner.RunRawAsync(new[] { "-c", "print('hi')" });
Assert.Equal(0, result.ExitCode);
Assert.Contains("hi", result.Output);
}
}
```
- [ ] **Step 3: Run test to verify it fails**
Run: `dotnet test tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj`
Expected: FAIL to compile — `BotRunner` / `RunRawAsync` not defined.
- [ ] **Step 4: Implement BotRunner**
Create `tools/JournalBot.Shared/BotRunner.cs`:
```csharp
using System.Diagnostics;
using System.Text;
namespace JournalBot.Shared;
public sealed record BotResult(int ExitCode, string Output);
public sealed class BotRunner
{
private readonly string _pythonExe;
private readonly string _workingDir;
public BotRunner(string pythonExe, string workingDir)
{
_pythonExe = pythonExe;
_workingDir = workingDir;
}
// Runs `python -m journal_bot <command>`.
public Task<BotResult> RunAsync(string command, CancellationToken ct = default)
=> RunRawAsync(new[] { "-m", "journal_bot", command }, ct);
// Runs python with arbitrary args (used by tests and RunAsync).
public async Task<BotResult> RunRawAsync(IReadOnlyList<string> args, CancellationToken ct = default)
{
var psi = new ProcessStartInfo
{
FileName = _pythonExe,
WorkingDirectory = _workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
foreach (var a in args) psi.ArgumentList.Add(a);
var sb = new StringBuilder();
using var proc = new Process { StartInfo = psi };
proc.OutputDataReceived += (_, e) => { if (e.Data is not null) sb.AppendLine(e.Data); };
proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) sb.AppendLine(e.Data); };
proc.Start();
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
await proc.WaitForExitAsync(ct);
return new BotResult(proc.ExitCode, sb.ToString());
}
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `dotnet test tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj`
Expected: PASS (or no-op return if venv absent — acceptable).
- [ ] **Step 6: Commit**
```bash
git add tools/JournalBot.Shared/BotRunner.cs tools/JournalBot.Shared.Tests
git commit -m "feat(tools): add BotRunner subprocess wrapper with test"
```
---
### Task 6: RuntimeReader (reads queue dirs + logs)
`RuntimeReader` reads `done/`, counts `pending/` + `failed/`, and tails log files. Pure file I/O → fully unit-testable with a temp dir.
**Files:**
- Create: `tools/JournalBot.Shared/RuntimeReader.cs`
- Create: `tools/JournalBot.Shared.Tests/RuntimeReaderTests.cs`
- [ ] **Step 1: Write the failing tests**
Create `tools/JournalBot.Shared.Tests/RuntimeReaderTests.cs`:
```csharp
using JournalBot.Shared;
using Xunit;
public class RuntimeReaderTests
{
private static string MakeRuntime()
{
var root = Path.Combine(Path.GetTempPath(), "jb_test_" + Guid.NewGuid().ToString("N"));
foreach (var sub in new[] { "queue/pending", "queue/done", "queue/failed", "logs" })
Directory.CreateDirectory(Path.Combine(root, sub));
return root;
}
[Fact]
public void ReadHistory_parsesDoneItemsNewestFirst()
{
var root = MakeRuntime();
var done = Path.Combine(root, "queue", "done");
File.WriteAllText(Path.Combine(done, "1.json"),
"{\"update_id\":1,\"received_at\":\"2026-06-14T10:00:00+02:00\",\"type\":\"text\",\"text\":\"a\",\"target_path\":\"05 Daily Notes/2026-06-14.md\",\"processed_at\":\"2026-06-14T11:00:00+02:00\"}");
File.WriteAllText(Path.Combine(done, "2.json"),
"{\"update_id\":2,\"received_at\":\"2026-06-14T12:00:00+02:00\",\"type\":\"voice\",\"text\":\"b\",\"processed_at\":\"2026-06-14T13:00:00+02:00\"}");
var reader = new RuntimeReader(root);
var history = reader.ReadHistory();
Assert.Equal(2, history.Count);
Assert.Equal(2, history[0].UpdateId); // newest processed_at first
Assert.Equal("05 Daily Notes/2026-06-14.md", history[1].TargetPath);
}
[Fact]
public void ReadStatus_countsPendingAndFailed()
{
var root = MakeRuntime();
File.WriteAllText(Path.Combine(root, "queue", "pending", "1.json"), "{}");
File.WriteAllText(Path.Combine(root, "queue", "pending", "2.json"), "{}");
File.WriteAllText(Path.Combine(root, "queue", "failed", "3.json"), "{}");
var status = new RuntimeReader(root).ReadStatus();
Assert.Equal(2, status.Pending);
Assert.Equal(1, status.Failed);
}
[Fact]
public void TailLog_returnsLastLines()
{
var root = MakeRuntime();
File.WriteAllText(Path.Combine(root, "logs", "service.log"),
string.Join("\n", Enumerable.Range(1, 100).Select(i => $"line {i}")));
var lines = new RuntimeReader(root).TailLog("service.log", 5);
Assert.Equal(5, lines.Count);
Assert.Equal("line 100", lines[^1]);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj`
Expected: FAIL to compile — `RuntimeReader` not defined.
- [ ] **Step 3: Implement RuntimeReader**
Create `tools/JournalBot.Shared/RuntimeReader.cs`:
```csharp
using System.Text.Json;
namespace JournalBot.Shared;
public sealed record QueueStatus(int Pending, int Failed);
public sealed class RuntimeReader
{
private readonly string _root;
public RuntimeReader(string runtimeRoot) => _root = runtimeRoot;
private string Queue(string sub) => Path.Combine(_root, "queue", sub);
public IReadOnlyList<QueueItem> ReadHistory()
{
var dir = Queue("done");
if (!Directory.Exists(dir)) return Array.Empty<QueueItem>();
var items = new List<QueueItem>();
foreach (var f in Directory.EnumerateFiles(dir, "*.json"))
{
try
{
var item = JsonSerializer.Deserialize<QueueItem>(File.ReadAllText(f));
if (item is not null) items.Add(item);
}
catch (JsonException) { /* skip malformed */ }
}
// Newest first by processed_at (fallback received_at).
return items
.OrderByDescending(i => i.ProcessedAt ?? i.ReceivedAt, StringComparer.Ordinal)
.ToList();
}
public QueueStatus ReadStatus()
{
int Count(string sub) => Directory.Exists(Queue(sub))
? Directory.EnumerateFiles(Queue(sub), "*.json").Count()
: 0;
return new QueueStatus(Count("pending"), Count("failed"));
}
public IReadOnlyList<string> TailLog(string fileName, int lines)
{
var path = Path.Combine(_root, "logs", fileName);
if (!File.Exists(path)) return Array.Empty<string>();
var all = File.ReadAllLines(path);
return all.Length <= lines ? all : all[^lines..];
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tools/JournalBot.Shared.Tests/JournalBot.Shared.Tests.csproj`
Expected: all PASS.
- [ ] **Step 5: Commit**
```bash
git add tools/JournalBot.Shared/RuntimeReader.cs tools/JournalBot.Shared.Tests/RuntimeReaderTests.cs
git commit -m "feat(tools): add RuntimeReader for history, status, log tail"
```
---
## Phase D — .NET: Windows Service
### Task 7: Worker Service that runs ingest on a timer
**Files:**
- Create: `tools/JournalBot.Service/JournalBot.Service.csproj`
- Create: `tools/JournalBot.Service/Program.cs`
- Create: `tools/JournalBot.Service/Worker.cs`
- Create: `tools/JournalBot.Service/appsettings.json`
- [ ] **Step 1: Scaffold the worker project**
```bash
dotnet new worker -o tools/JournalBot.Service -n JournalBot.Service
dotnet sln tools/JournalBot.sln add tools/JournalBot.Service/JournalBot.Service.csproj
dotnet add tools/JournalBot.Service/JournalBot.Service.csproj reference tools/JournalBot.Shared/JournalBot.Shared.csproj
dotnet add tools/JournalBot.Service/JournalBot.Service.csproj package Microsoft.Extensions.Hosting.WindowsServices
```
Delete the generated `tools/JournalBot.Service/Worker.cs` (we replace it below).
- [ ] **Step 2: Write appsettings.json**
Create `tools/JournalBot.Service/appsettings.json`:
```json
{
"Logging": {
"LogLevel": { "Default": "Information", "Microsoft.Hosting.Lifetime": "Information" }
},
"JournalBot": {
"PythonExe": "D:\\projects\\chrka\\journal-bot\\.venv\\Scripts\\python.exe",
"WorkingDir": "D:\\projects\\chrka\\journal-bot",
"IntervalMinutes": 15
}
}
```
- [ ] **Step 3: Write Program.cs**
Create `tools/JournalBot.Service/Program.cs`:
```csharp
using JournalBot.Service;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(o => o.ServiceName = "JournalBotService");
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();
```
- [ ] **Step 4: Write Worker.cs**
Create `tools/JournalBot.Service/Worker.cs`:
```csharp
using JournalBot.Shared;
namespace JournalBot.Service;
public sealed class Worker : BackgroundService
{
private readonly ILogger<Worker> _log;
private readonly BotRunner _runner;
private readonly TimeSpan _interval;
private readonly string _serviceLogPath;
public Worker(ILogger<Worker> log, IConfiguration config)
{
_log = log;
var py = config["JournalBot:PythonExe"]!;
var workingDir = config["JournalBot:WorkingDir"]!;
var minutes = config.GetValue("JournalBot:IntervalMinutes", 15);
_runner = new BotRunner(py, workingDir);
_interval = TimeSpan.FromMinutes(minutes);
_serviceLogPath = Path.Combine(workingDir, "runtime", "logs", "service.log");
Directory.CreateDirectory(Path.GetDirectoryName(_serviceLogPath)!);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Run once at startup, then every interval.
await RunIngestAsync(stoppingToken);
using var timer = new PeriodicTimer(_interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
await RunIngestAsync(stoppingToken);
}
private async Task RunIngestAsync(CancellationToken ct)
{
try
{
var result = await _runner.RunAsync("ingest", ct);
var line = $"[{DateTimeOffset.Now:O}] ingest exit={result.ExitCode}";
_log.LogInformation("{Line}", line);
await AppendLogAsync(line + "\n" + result.Output);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_log.LogError(ex, "ingest run failed");
await AppendLogAsync($"[{DateTimeOffset.Now:O}] ingest ERROR: {ex.Message}");
}
}
private async Task AppendLogAsync(string text)
{
try { await File.AppendAllTextAsync(_serviceLogPath, text + "\n", System.Text.Encoding.UTF8); }
catch { /* logging must never crash the service */ }
}
}
```
- [ ] **Step 5: Build to verify it compiles**
Run: `dotnet build tools/JournalBot.Service/JournalBot.Service.csproj`
Expected: Build succeeded.
- [ ] **Step 6: Commit**
```bash
git add tools/JournalBot.Service
git commit -m "feat(service): windows service runs ingest every 15 min via BotRunner"
```
---
## Phase E — .NET: WPF Tray GUI
### Task 8: WPF project with tray icon and window shell
**Files:**
- Create: `tools/JournalBot.Tray/JournalBot.Tray.csproj`
- Create: `tools/JournalBot.Tray/App.xaml`
- Create: `tools/JournalBot.Tray/App.xaml.cs`
- Create: `tools/JournalBot.Tray/MainWindow.xaml`
- Create: `tools/JournalBot.Tray/MainWindow.xaml.cs`
- Create: `tools/JournalBot.Tray/appsettings.json`
- Create: `tools/JournalBot.Tray/icon.ico` (placeholder, see step)
- [ ] **Step 1: Scaffold the WPF project**
```bash
dotnet new wpf -o tools/JournalBot.Tray -n JournalBot.Tray
dotnet sln tools/JournalBot.sln add tools/JournalBot.Tray/JournalBot.Tray.csproj
dotnet add tools/JournalBot.Tray/JournalBot.Tray.csproj reference tools/JournalBot.Shared/JournalBot.Shared.csproj
dotnet add tools/JournalBot.Tray/JournalBot.Tray.csproj package Hardcodet.NotifyIcon.Wpf
dotnet add tools/JournalBot.Tray/JournalBot.Tray.csproj package Microsoft.Extensions.Configuration.Json
```
- [ ] **Step 2: Add an icon**
Copy any existing `.ico` to `tools/JournalBot.Tray/icon.ico`. If none exists, generate a 1-color placeholder:
```bash
.venv/Scripts/python.exe -c "from PIL import Image; Image.new('RGBA',(32,32),(30,90,200,255)).save('tools/JournalBot.Tray/icon.ico')"
```
In `tools/JournalBot.Tray/JournalBot.Tray.csproj`, inside an `<ItemGroup>`, mark it as resource:
```xml
<ItemGroup>
<Resource Include="icon.ico" />
</ItemGroup>
```
- [ ] **Step 3: Write appsettings.json**
Create `tools/JournalBot.Tray/appsettings.json`:
```json
{
"JournalBot": {
"PythonExe": "D:\\projects\\chrka\\journal-bot\\.venv\\Scripts\\python.exe",
"WorkingDir": "D:\\projects\\chrka\\journal-bot",
"RuntimePath": "D:\\projects\\chrka\\journal-bot\\runtime",
"ProcessTimerMinutes": 0
}
}
```
In the `.csproj`, ensure it is copied next to the EXE — add inside an `<ItemGroup>`:
```xml
<ItemGroup>
<None Update="appsettings.json"><CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory></None>
</ItemGroup>
```
- [ ] **Step 4: Write App.xaml (no StartupUri; tray-driven)**
Replace `tools/JournalBot.Tray/App.xaml`:
```xml
<Application x:Class="JournalBot.Tray.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnExplicitShutdown">
<Application.Resources/>
</Application>
```
- [ ] **Step 5: Write App.xaml.cs (sets up tray icon + config)**
Replace `tools/JournalBot.Tray/App.xaml.cs`:
```csharp
using System.IO;
using System.Windows;
using Hardcodet.Wpf.TaskbarNotification;
using JournalBot.Shared;
using Microsoft.Extensions.Configuration;
namespace JournalBot.Tray;
public partial class App : Application
{
private TaskbarIcon? _tray;
private MainWindow? _window;
private BotRunner _runner = null!;
private RuntimeReader _reader = null!;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false)
.Build();
var py = config["JournalBot:PythonExe"]!;
var workingDir = config["JournalBot:WorkingDir"]!;
var runtimePath = config["JournalBot:RuntimePath"]!;
_runner = new BotRunner(py, workingDir);
_reader = new RuntimeReader(runtimePath);
_window = new MainWindow(_runner, _reader);
_tray = new TaskbarIcon
{
ToolTipText = "JournalBot",
Icon = new System.Drawing.Icon(
Path.Combine(AppContext.BaseDirectory, "icon.ico")),
};
_tray.TrayLeftMouseUp += (_, _) => ToggleWindow();
_tray.ContextMenu = BuildMenu();
}
private System.Windows.Controls.ContextMenu BuildMenu()
{
var menu = new System.Windows.Controls.ContextMenu();
void Add(string header, Action onClick)
{
var mi = new System.Windows.Controls.MenuItem { Header = header };
mi.Click += (_, _) => onClick();
menu.Items.Add(mi);
}
Add("Ingest", () => _window!.TriggerCommand("ingest"));
Add("Process", () => _window!.TriggerCommand("process"));
Add("Both", () => _window!.TriggerCommand("both"));
menu.Items.Add(new System.Windows.Controls.Separator());
Add("Fenster öffnen", ShowWindow);
Add("Beenden", () => Shutdown());
return menu;
}
private void ToggleWindow()
{
if (_window!.IsVisible) _window.Hide();
else ShowWindow();
}
private void ShowWindow()
{
_window!.Show();
_window.Activate();
}
protected override void OnExit(ExitEventArgs e)
{
_tray?.Dispose();
base.OnExit(e);
}
}
```
- [ ] **Step 6: Build to verify it compiles**
Run: `dotnet build tools/JournalBot.Tray/JournalBot.Tray.csproj`
Expected: Build succeeded. (MainWindow ctor with `(BotRunner, RuntimeReader)` will be defined in Task 9 — until then this fails to compile. So defer the build to Task 9.)
- [ ] **Step 7: Commit**
```bash
git add tools/JournalBot.Tray
git commit -m "feat(tray): WPF app shell with tray icon and context menu"
```
---
### Task 9: MainWindow — status banner, history tab, log tab, buttons
**Files:**
- Modify: `tools/JournalBot.Tray/MainWindow.xaml`
- Modify: `tools/JournalBot.Tray/MainWindow.xaml.cs`
- [ ] **Step 1: Write MainWindow.xaml**
Replace `tools/JournalBot.Tray/MainWindow.xaml`:
```xml
<Window x:Class="JournalBot.Tray.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="JournalBot" Height="500" Width="760"
WindowStartupLocation="CenterScreen">
<DockPanel>
<Border DockPanel.Dock="Top" Background="#222" Padding="8">
<TextBlock x:Name="StatusBanner" Foreground="White" FontSize="13"
Text=" pending · failed"/>
</Border>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="8">
<Button x:Name="BtnIngest" Content="Ingest" Width="90" Margin="0,0,8,0"
Click="BtnIngest_Click"/>
<Button x:Name="BtnProcess" Content="Process" Width="90" Margin="0,0,8,0"
Click="BtnProcess_Click"/>
<Button x:Name="BtnBoth" Content="Both" Width="90" Margin="0,0,8,0"
Click="BtnBoth_Click"/>
<Button x:Name="BtnRefresh" Content="Refresh" Width="90"
Click="BtnRefresh_Click"/>
</StackPanel>
<TabControl Margin="8">
<TabItem Header="Historie">
<DataGrid x:Name="HistoryGrid" AutoGenerateColumns="False"
IsReadOnly="True" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Zeit" Binding="{Binding ReceivedAt}" Width="160"/>
<DataGridTextColumn Header="Typ" Binding="{Binding Type}" Width="60"/>
<DataGridTextColumn Header="Text" Binding="{Binding Text}" Width="220"/>
<DataGridTextColumn Header="Ziel" Binding="{Binding TargetPath}" Width="*"/>
</DataGrid.Columns>
</DataGrid>
</TabItem>
<TabItem Header="Log">
<TextBox x:Name="LogBox" IsReadOnly="True" FontFamily="Consolas"
VerticalScrollBarVisibility="Auto" TextWrapping="NoWrap"
HorizontalScrollBarVisibility="Auto"/>
</TabItem>
</TabControl>
</DockPanel>
</Window>
```
- [ ] **Step 2: Write MainWindow.xaml.cs**
Replace `tools/JournalBot.Tray/MainWindow.xaml.cs`:
```csharp
using System.ComponentModel;
using System.Windows;
using JournalBot.Shared;
namespace JournalBot.Tray;
public partial class MainWindow : Window
{
private readonly BotRunner _runner;
private readonly RuntimeReader _reader;
public MainWindow(BotRunner runner, RuntimeReader reader)
{
_runner = runner;
_reader = reader;
InitializeComponent();
Refresh();
}
// Hide instead of close (keep app alive in tray).
protected override void OnClosing(CancelEventArgs e)
{
e.Cancel = true;
Hide();
}
public void Refresh()
{
var status = _reader.ReadStatus();
StatusBanner.Text = $"{status.Pending} pending · {status.Failed} failed";
HistoryGrid.ItemsSource = _reader.ReadHistory();
LogBox.Text = string.Join("\n", _reader.TailLog("service.log", 200));
}
public async void TriggerCommand(string command)
{
SetButtons(false);
try
{
var result = await _runner.RunAsync(command);
LogBox.Text = result.Output + "\n" + LogBox.Text;
Refresh();
}
finally { SetButtons(true); }
}
private void SetButtons(bool enabled)
{
BtnIngest.IsEnabled = enabled;
BtnProcess.IsEnabled = enabled;
BtnBoth.IsEnabled = enabled;
}
private void BtnIngest_Click(object sender, RoutedEventArgs e) => TriggerCommand("ingest");
private void BtnProcess_Click(object sender, RoutedEventArgs e) => TriggerCommand("process");
private void BtnBoth_Click(object sender, RoutedEventArgs e) => TriggerCommand("both");
private void BtnRefresh_Click(object sender, RoutedEventArgs e) => Refresh();
}
```
- [ ] **Step 3: Build the whole solution**
Run: `dotnet build tools/JournalBot.sln`
Expected: Build succeeded (Service, Tray, Shared, Tests all compile).
- [ ] **Step 4: Run the .NET tests**
Run: `dotnet test tools/JournalBot.sln`
Expected: all PASS.
- [ ] **Step 5: Manual smoke test (tray)**
Run: `dotnet run --project tools/JournalBot.Tray/JournalBot.Tray.csproj`
Expected: tray icon appears; left-click opens window; status banner shows counts; Refresh works; closing window hides it; "Beenden" exits. (No LM Studio needed to verify UI; Process button may report exit!=0 — acceptable.)
- [ ] **Step 6: Commit**
```bash
git add tools/JournalBot.Tray/MainWindow.xaml tools/JournalBot.Tray/MainWindow.xaml.cs
git commit -m "feat(tray): main window with status, history, log tabs and command buttons"
```
---
## Phase F — Install scripts + docs
### Task 10: Install scripts
**Files:**
- Create: `scripts/install-service.ps1`
- Create: `scripts/install-tray.ps1`
- Create: `scripts/build-tools.ps1`
- Delete: `scripts/install-task.ps1` (replaced by the two above)
- [ ] **Step 1: Write build-tools.ps1**
Create `scripts/build-tools.ps1`:
```powershell
# Publishes both .NET tools as self-contained win-x64 EXEs into tools/publish.
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$out = Join-Path $root "tools\publish"
dotnet publish "$root\tools\JournalBot.Service\JournalBot.Service.csproj" `
-c Release -r win-x64 --self-contained -o "$out\Service"
dotnet publish "$root\tools\JournalBot.Tray\JournalBot.Tray.csproj" `
-c Release -r win-x64 --self-contained -o "$out\Tray"
Write-Host "Published to $out"
```
- [ ] **Step 2: Write install-service.ps1**
Create `scripts/install-service.ps1`:
```powershell
# Registers JournalBotService (runs ingest every 15 min). Run as Administrator.
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$exe = Join-Path $root "tools\publish\Service\JournalBot.Service.exe"
if (-not (Test-Path $exe)) { throw "Build first: scripts\build-tools.ps1" }
$existing = Get-Service -Name "JournalBotService" -ErrorAction SilentlyContinue
if ($existing) { sc.exe delete "JournalBotService" | Out-Null; Start-Sleep 2 }
New-Service -Name "JournalBotService" -BinaryPathName "`"$exe`"" `
-DisplayName "JournalBot Ingest Service" -StartupType Automatic `
-Description "Polls Telegram every 15 minutes and queues journal items."
Start-Service "JournalBotService"
Write-Host "Service 'JournalBotService' installed and started."
```
- [ ] **Step 3: Write install-tray.ps1**
Create `scripts/install-tray.ps1`:
```powershell
# Registers the tray GUI to autostart at logon via the Run registry key.
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$exe = Join-Path $root "tools\publish\Tray\JournalBot.Tray.exe"
if (-not (Test-Path $exe)) { throw "Build first: scripts\build-tools.ps1" }
$runKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
Set-ItemProperty -Path $runKey -Name "JournalBotTray" -Value "`"$exe`""
Write-Host "Tray app registered at logon. Start now: $exe"
```
- [ ] **Step 4: Delete the obsolete task installer**
```bash
git rm scripts/install-task.ps1
```
- [ ] **Step 5: Commit**
```bash
git add scripts/build-tools.ps1 scripts/install-service.ps1 scripts/install-tray.ps1
git commit -m "feat(scripts): build + install scripts for service and tray; drop install-task"
```
---
### Task 11: Update CLAUDE.md
**Files:**
- Modify: `CLAUDE.md`
- [ ] **Step 1: Add a "Service + Tray (tools/)" section**
In `CLAUDE.md`, after the "Deployment" section, add:
```markdown
## Service + Tray (tools/)
Two thin .NET 10 shells around the Python CLI (`tools/JournalBot.sln`):
- **JournalBot.Service** — Windows Service (LocalSystem), `PeriodicTimer` runs `python -m journal_bot ingest` every `IntervalMinutes` (default 15). Config in `appsettings.json` next to the EXE. Logs to `runtime/logs/service.log` + Event Log. Only `ingest` — no LM Studio needed.
- **JournalBot.Tray** — WPF tray app (user process, autostart @logon). Tray icon + window with status banner (pending/failed), Historie tab (done items, newest first, with target daily note), Log tab, and Ingest/Process/Both buttons. Runs `process` here because LM Studio is a user-session app.
- **JournalBot.Shared** — `QueueItem` DTO (mirrors the pydantic model, snake_case JSON), `BotRunner` (subprocess wrapper), `RuntimeReader` (reads done/, counts pending/failed, tails logs). xUnit tests in `JournalBot.Shared.Tests`.
Build + install:
\`\`\`powershell
scripts\build-tools.ps1 # publish self-contained EXEs to tools\publish
scripts\install-service.ps1 # (Admin) register + start JournalBotService
scripts\install-tray.ps1 # autostart tray @logon (HKCU Run key)
\`\`\`
.NET tests: `dotnet test tools/JournalBot.sln`.
**Gotcha:** the GUI's history shows `target_path`/`written_entry`/`processed_at`, which `process` now records on done items (`queue.py` `complete()` rewrites the JSON instead of plain rename). Old done items lack these fields (all optional → render blank).
```
(Note: the triple-backtick `powershell` block above is escaped in this plan with backslashes; write a real fenced block in CLAUDE.md.)
- [ ] **Step 2: Commit**
```bash
git add CLAUDE.md
git commit -m "docs: document service + tray tools in CLAUDE.md"
```
---
## Final Verification
- [ ] **Python suite green:** `.venv/Scripts/python.exe -m pytest -v` → all pass (≥40 tests).
- [ ] **.NET build + tests green:** `dotnet test tools/JournalBot.sln` → all pass.
- [ ] **Tray smoke test:** `dotnet run --project tools/JournalBot.Tray/JournalBot.Tray.csproj` → tray icon, window, status, tabs, buttons work.
- [ ] **Service publish:** `scripts/build-tools.ps1` → both EXEs produced under `tools/publish`.
---
## Open Questions
- `.env` resolution: confirm the bot loads `.env` relative to CWD (pydantic-settings `env_file=".env"` resolves relative to the process working dir). Service sets `WorkingDir` to project root → should work. Verify during Task 7 manual run.
- Tray autostart uses HKCU Run key (simplest). If Run key proves unreliable, switch to Task Scheduler @logon.
- Icon: placeholder generated via Pillow if no `.ico` exists. Replace with a real icon later (cosmetic).