1181 lines
40 KiB
Markdown
1181 lines
40 KiB
Markdown
# 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).
|