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

40 KiB
Raw Blame History

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:

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):

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
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:

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:

    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
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):

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:

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:

            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:

    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
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):

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]:

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
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

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:

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:

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
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:

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:

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
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

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:

{
  "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:

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:

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
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

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:

.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:

  <ItemGroup>
    <Resource Include="icon.ico" />
  </ItemGroup>
  • Step 3: Write appsettings.json

Create tools/JournalBot.Tray/appsettings.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>:

  <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:

<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:

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
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:

<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:

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
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:

# 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:

# 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:

# 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
git rm scripts/install-task.ps1
  • Step 5: Commit
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:

## 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
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).