40 KiB
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 PowerShellSet-Content/Get-Contentwithout-Encoding UTF8. - Commits: conventional, English, imperative. Commit per task.
- Paths: project root
D:\projects\chrka\journal-bot. .NET projects undertools/.
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(completemethod) -
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_processpassesprocessed_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_oncesignature 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 undertools/publish.
Open Questions
.envresolution: confirm the bot loads.envrelative to CWD (pydantic-settingsenv_file=".env"resolves relative to the process working dir). Service setsWorkingDirto 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
.icoexists. Replace with a real icon later (cosmetic).