Compare commits
48 Commits
14cc9fb891
...
a6608bf8b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6608bf8b3 | ||
|
|
df66c4af46 | ||
|
|
4c92da55ad | ||
|
|
d4d5a4b8e7 | ||
|
|
9ba238f4ad | ||
|
|
c1856657b5 | ||
|
|
47b07373af | ||
|
|
121e8cd476 | ||
|
|
cfbe2fd7e3 | ||
|
|
5079a5fc5c | ||
|
|
618235d8ed | ||
|
|
bca8c9e4cb | ||
|
|
8b02b63d3d | ||
|
|
f890fa85b9 | ||
|
|
fd5562b6e8 | ||
|
|
71c6c68c84 | ||
|
|
507f59f1d1 | ||
|
|
13c280f6d5 | ||
|
|
09e3e7e8b5 | ||
|
|
975db8ab54 | ||
|
|
f383645360 | ||
|
|
4e90828653 | ||
|
|
a335a3b684 | ||
|
|
0b90df6ff0 | ||
|
|
6c9ccf68b6 | ||
|
|
2ff0971dce | ||
|
|
8eafa71ed3 | ||
|
|
dc3fc443b4 | ||
|
|
ff7c239959 | ||
|
|
4ab906ff0b | ||
|
|
064a903076 | ||
|
|
8823265e5a | ||
|
|
cf7a6e413c | ||
|
|
7b737e6717 | ||
|
|
43af17e546 | ||
|
|
5c55f6c6cf | ||
|
|
bdb709b264 | ||
|
|
2d7f825ff3 | ||
|
|
721c36a66b | ||
|
|
10b2ca817b | ||
|
|
1b9f2d4de1 | ||
|
|
59dc1e2357 | ||
|
|
31a394e694 | ||
|
|
d99cb68afb | ||
|
|
1a74e1c058 | ||
|
|
e6846b7e6d | ||
|
|
e767d57640 | ||
|
|
25493528de |
355
docs/open.md
355
docs/open.md
@@ -1,228 +1,281 @@
|
||||
# ClaudeDo — Offene Punkte
|
||||
|
||||
Stand: 2026-04-13 nach Slice F. Branch `main` @ `48e4aab`. Alle Tests grün (38/38), Build 0 Warnings.
|
||||
Stand: 2026-04-30. Neu erstellt nach Code-Audit gegen `plan.md`, `improvement-plan.md` und `mailbox-proposal.md`.
|
||||
|
||||
Dieses Dokument listet alles, was noch fehlt — gruppiert nach Aufwand/Risiko und mit konkreten Datei-Pointern, damit wir es in der IDE der Reihe nach durchgehen können.
|
||||
Die alte Version dieses Dokuments war auf 2026-04-13 ("nach Slice F") datiert und ignorierte die seither gelandeten Slices (Planning Sessions, Prime Claude, Self-Update, Externe MCP-Tools, editierbare Status/Tags, BlockedBy-Chains). Diese Version trennt sauber zwischen **erledigt**, **teilweise**, **offen** — und listet das, was inzwischen gebaut wurde, explizit als „shipped" auf, damit es nicht verloren geht.
|
||||
|
||||
Legende: ✅ DONE — 🟡 PARTIAL — ⬜ OPEN — ⛔ DROPPED
|
||||
|
||||
---
|
||||
|
||||
## 0. Was seit dem 2026-04-13 dazugekommen ist
|
||||
|
||||
Diese Slices gab es im alten Dokument noch nicht (oder nur als Platzhalter). Sie sind **fertig im Code**, brauchen aber jeweils noch ein paar Polish-Punkte (siehe Sektion 2/3).
|
||||
|
||||
| Slice | Worker-Anker | UI-Anker | Status |
|
||||
|---|---|---|---|
|
||||
| **Planning Sessions** (Plan B+C) | `Planning/PlanningSessionManager`, `PlanningChainCoordinator`, `PlanningMcpService` | `Views/Planning/PlanningDiffView`, `ConflictResolutionView`, `UnfinishedPlanningModalView` | ✅ Code, manuelle Verifikation siehe §1.1 |
|
||||
| **Prime Claude** (geplante Recurrence) | `Prime/PrimeScheduler`, `NextDueCalculator`, `PrimeRunner` | `ViewModels/Modals/PrimeClaudeTabViewModel`, `Views/Controls/ThemedDatePicker` | ✅ Code, manuelle Verifikation siehe §1.2 |
|
||||
| **Self-Update System** (Gitea Releases) | — | `ClaudeDo.Releases` (`ReleaseClient`, `SelfUpdater`, `ChecksumVerifier`, `VersionComparer`), `ClaudeDo.Installer` (Pages/Steps/Core) | ✅ Code, manuelle Verifikation siehe §1.3 |
|
||||
| **Externes MCP-Endpoint** (11 Tools für Drittsessions) | `External/ExternalMcpService` (`ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus`, `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`), `ExternalMcpAuthMiddleware` (X-ClaudeDo-Key) | — | ✅ Code, ohne Tests am Endpoint selbst |
|
||||
| **Editierbare Status & Tags** (entkoppelt vom `agent`-Tag) | `WorkerHub.SetTaskStatus`, `SetTaskTags`, `UpdateTaskAgentSettings`; Queue-Picker filtert nicht mehr nach `agent`-Tag | `DetailsIslandViewModel`, Status-/Tag-Kontextmenü in `TasksIslandView` | ✅ Code |
|
||||
| **BlockedBy-Chains** (sequenzielle Subtask-Ausführung) | `TaskStateService.BlockOn`/`UnblockAsync`, `QueuePicker` filter `BlockedByTaskId IS NULL`, `PlanningChainCoordinator.OnChildFinishedAsync` | Drittes Feld neben `Status` und `PlanningPhase` | ✅ Code, Migration `20260423154708_AddPlanningSupport` |
|
||||
| **Worker-State-Konsolidierung** | `TaskStateService` ist alleiniger Owner von `Status`/`PlanningPhase`/`BlockedByTaskId`-Writes; `OverrideSlotService` ausgelagert; `QueueWaker` + `QueuePicker` getrennt | — | ✅ Code |
|
||||
| **MarkdownView / Tabbed Settings / About-Modal / Prime-Status-Footer / Doppelklick-Edit** | — | `Views/MarkdownView`, `SettingsModalView` als `TabControl`, `AboutModalView`, transient Prime-Status in Footer, `DoubleTapped` an List/Task-Rows | ✅ Code |
|
||||
|
||||
---
|
||||
|
||||
## 1. Verification (vor allem anderen)
|
||||
|
||||
Die in `plan.md` definierten Verification-Steps sind teilweise nur durch Build/Tests abgedeckt. Diese sollten manuell einmal durchlaufen werden, BEVOR wir Polish bauen — damit wir wissen, was tatsächlich kaputt ist.
|
||||
Der Großteil der Verification-Steps aus `plan.md` ist im Code abgedeckt — was fehlt ist die **manuelle Bestätigung mit explizit notiertem Pass-Kriterium**. Ohne falsifizierbare Observable produziert ein Manual-Run nur "sah ok aus".
|
||||
|
||||
| # | Plan | Status | Was tun |
|
||||
|---|------|--------|---------|
|
||||
| 1 | Schema-Init | Auto verifiziert (Worker startet ohne Crash, WAL-Files entstehen) | OK |
|
||||
| 1a | SignalR-Endpoint | Manuell verifiziert (HTTP 400 auf `/hub` ohne Handshake) | OK |
|
||||
| 1b | Hub-Roundtrip `Ping` | **Nicht getestet** | Test-Client schreiben oder UI starten und im Log nach "pong" schauen |
|
||||
| 2 | `claude --version` Preflight | **Nicht implementiert** | `Worker/Program.cs`: vor `app.Run()` einmal `claude --version` shellen und bei Exit≠0 abbrechen |
|
||||
| 3 | Smoke-Spawn (`claude -p` mit Prompt "ping") | **Nicht getestet** | Integrationstest schreiben oder einmal manuell laufen lassen |
|
||||
| 4 | E2E Happy Path (Non-Worktree) | **Nicht getestet** | UI starten → Liste "Test" anlegen → Task mit Tag `agent` + Status `queued` + Description "Schreibe ein Haiku über Intralogistik" → Run abwarten → Result prüfen |
|
||||
| 5 | Worktree Happy Path | **Nicht getestet** | Manueller Test mit echtem Repo (z.B. einem temp-Repo) |
|
||||
| 6 | No-Changes-Run | **Nicht getestet** | Prompt der nichts ändert → `head_commit` bleibt NULL |
|
||||
| 7 | Kein Git-Repo | **Nicht getestet** | working_dir auf `C:\Temp` → Task `failed`, keine `worktrees`-Row |
|
||||
| 8 | Merge-UI | **Nicht getestet** (UI ruft `GitService.MergeFfOnlyAsync`, aber nie ausgeführt) | Manuell |
|
||||
| 9 | Override-Parallelität | Tests vorhanden für Slot-Logik, **End-to-End nicht** | UI: zwei Tasks queuen, `Run Now` auf der zweiten → beide laufen parallel |
|
||||
| 10 | Schedule | Logik per Test abgedeckt, **End-to-End nicht** | Task mit `scheduled_for = now+2min` |
|
||||
| 11 | Worker-Offline-Erkennung | UI hat Status-Bar, aber **nicht visuell verifiziert** | Worker killen, schauen ob Status auf "offline" wechselt |
|
||||
| 12 | Live-Stream | **Nicht getestet** | Während Run TaskDetail öffnen, beobachten ob ndjson-Zeilen erscheinen |
|
||||
| 13 | Wake-up (UI ruft `WakeQueue` nach Anlage) | Implementiert in `TaskListViewModel`, **nicht visuell verifiziert** | Tasks nach Anlage in <1s gepickert |
|
||||
### 1.0 Plan-Verification 1–13
|
||||
|
||||
**Vorschlag:** Wir machen einmal Step 4 (Haiku-Happy-Path) gemeinsam — wenn das läuft, ist die ganze Pipeline lebendig.
|
||||
| # | Item | Status | Pass-Kriterium (was muss konkret zu sehen sein) |
|
||||
|---|------|---|---|
|
||||
| 1 | Schema-Init | ✅ | `~/.todo-app/todo.db` + `*-wal` + `*-shm` existieren; EF-Migrationsverlauf in `__EFMigrationsHistory` enthält alle 8 Migrationen; Worker-Log: „listening on …" |
|
||||
| 1a | SignalR-Endpoint | ✅ | `curl http://127.0.0.1:47821/hub` → HTTP 400 (kein Handshake) |
|
||||
| 1b | Hub-Roundtrip `Ping` | 🟡 | UI-Statusbar zeigt „Connected"; `WorkerClient.PingAsync()` liefert `"pong"` (UI-Test fehlt) |
|
||||
| 2 | `claude --version` Preflight | ✅ | `Worker/Lifecycle/ClaudeCliPreflight.cs` + Wiring in `Program.cs`. Kaputter `claude_bin` → `LogCritical(...) + Environment.Exit(1)`. Skip via `CLAUDEDO_SKIP_CLI_PREFLIGHT=1`. Tests: `tests/.../Lifecycle/ClaudeCliPreflightTests.cs` |
|
||||
| 3 | Smoke-Spawn (`claude -p` Prompt „ping") | ⬜ | `task_runs`-Row mit `session_id NOT NULL`, `result` non-empty, `output_tokens > 0` |
|
||||
| 4 | E2E Happy Path (Non-Worktree) | ⬜ | Liste „Test" anlegen → Task „Schreibe ein Haiku über Intralogistik" → `tasks.status='Done'`, `tasks.result IS NOT NULL`, Logfile unter `~/.todo-app/logs/<taskId>.ndjson`, UI-Row mit Done-Badge |
|
||||
| 5 | Worktree Happy Path | ⬜ | Liste mit `working_dir` auf temp-Repo, Task mit Codeänderung → `worktrees.state='active'`, `head_commit IS NOT NULL`, `diff_stat` non-empty, Branch `claudedo/<id>` auf Disk |
|
||||
| 6 | No-Changes-Run | ⬜ | Prompt der nichts ändert → `tasks.status='Done'` aber `worktrees.head_commit IS NULL`, `diff_stat IS NULL` |
|
||||
| 7 | Kein Git-Repo | ⬜ | `working_dir=C:\Temp` (kein Repo) → `tasks.status='Failed'`, **keine** `worktrees`-Row, Worker-Log enthält Git-Fehler |
|
||||
| 8 | Merge-UI | 🟡 | `MergeTask`-Hub-Methode + `MergeModalView` vorhanden, manueller Run nicht durchgespielt → `worktrees.state='merged'`, im Ziel-Repo `git log` zeigt Commit, `git worktree list` ohne Branch |
|
||||
| 9 | Override-Parallelität | 🟡 | `OverrideSlotService`-Tests grün; UI-E2E nicht durchgespielt → `WorkerHub.GetActive` ≥ 2 Einträge bei Run+RunNow |
|
||||
| 10 | Schedule | 🟡 | `QueuePicker`-Tests grün; UI-E2E nicht → `scheduled_for=now+2min` bleibt Queued, dann automatisch Running, `started_at >= scheduled_for` |
|
||||
| 11 | Worker-Offline-Erkennung | 🟡 | `WorkerClient.OnServerConnectionClosed` + Auto-Reconnect implementiert (`WithAutomaticReconnect`); visuell prüfen: nach `taskkill` der Worker-Exe wechselt Statusbar in ≤ 5s auf „Offline", `RunNow`-Buttons disabled |
|
||||
| 12 | Live-Stream | 🟡 | `ClaudeProcess` streamt NDJSON via `TaskMessage`-Event, UI hat `LiveTail`; visuell prüfen: während Run laufen ndjson-Zeilen ein |
|
||||
| 13 | Wake-up (`WakeQueue` nach Anlage) | 🟡 | `QueueWaker.Wake()` wird bei Enqueue aufgerufen; visuell prüfen: Task wechselt in ≤ 1s auf Running (statt nach `queue_backstop_interval_ms`=30s) |
|
||||
|
||||
**Empfohlener Sprint:** Steps 3–7 in einem Rutsch durchspielen (alles non-UI), parallel daneben 8–13 visuell beim normalen App-Lauf abhaken.
|
||||
|
||||
### 1.1 Planning Sessions — Manual Verification (unverändert relevant)
|
||||
|
||||
Bedingt durch Slice "Planning B/C". Ablauf identisch zur alten open.md:
|
||||
|
||||
1. Manual-Task mit Title + TODO-Description anlegen.
|
||||
2. Rechtsklick → **Open planning Session** → Windows Terminal mit Claude CLI öffnet.
|
||||
3. In CLI: zwei Children via `mcp__claudedo__create_child_task` anlegen.
|
||||
4. UI: Drafts erscheinen eingerückt, italic, mit `DRAFT`-Badge; Parent zeigt `PLANNING`-Badge.
|
||||
5. Chevron klappt ein/aus.
|
||||
6. CLI `finalize` → Children werden Queued (erste) bzw. Queued+BlockedBy (Rest); Parent flippt von `Active` auf `Finalized` (`PLANNED`-Badge); erste Child startet automatisch.
|
||||
7. Neuer Planning-Task, Terminal ohne Finalize schließen → Rechtsklick öffnet Resume/Finalize-now/Discard-Modal.
|
||||
8. Delete-Versuch auf Parent mit Children → freundlicher Fehlerdialog, kein Delete.
|
||||
|
||||
**Bekannte Follow-ups (non-blocking):**
|
||||
- ⬜ `Border.badge.planned` (blau) ist in `IslandStyles.axaml` definiert, wird aber nie angewendet — `TaskRowView` behält die `planning`-Klasse für `Active` UND `Finalized`, daher amber statt blau bei finalisiert. Entweder Class-Swap auf `planned` bei `Finalized`, oder die unused Style+Brush entfernen.
|
||||
- ⬜ Tote `Instance`-Statics auf `BoolToItalicConverter` und `BoolToDraftOpacityConverter` — `App.axaml` registriert via Resource-Dictionary, die statischen Members können weg.
|
||||
- ⬜ `Ui.Tests` IWorkerClient-Fakes (`DetailsIslandPlanningTests`, `PlanningDiffViewModelTests`, `ConflictResolutionViewModelTests`) fehlen `OpenInteractiveTerminalAsync` und `QueuePlanningSubtasksAsync` — Constructor-Drift, Fakes auf gemeinsame abstrakte Basis rebasen.
|
||||
|
||||
### 1.2 Prime Claude — Manual Verification
|
||||
|
||||
Slice "Prime" (Recurrence-Scheduler).
|
||||
|
||||
1. Settings → Prime-Claude-Tab → Schedule mit `at: 09:00`, `every: workday`, `task_template: "Daily Standup"` anlegen.
|
||||
2. Test mit verschobenem `IPrimeClock` (oder Schedule mit `at: now+1min`) → bei Trigger erscheint Toast/Footer-Notification „Prime fired", neuer Task entsteht in der Ziel-Liste.
|
||||
3. Worker-Restart innerhalb des Schedule-Fensters → Catch-up läuft genau einmal (kein Doppelfeuer).
|
||||
4. Schedule editieren → `next_due_at` wird neu berechnet; UI-Anzeige aktualisiert.
|
||||
5. Schedule löschen → keine weiteren Trigger, keine ghost-Tasks.
|
||||
|
||||
### 1.3 Self-Update — Manual Verification (aus alter open.md, weiterhin gültig)
|
||||
|
||||
Voraussetzung: funktionierendes Gitea-Release unter `git.kuns.dev/releases/ClaudeDo` mit drei Assets — `ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, `checksums.txt`.
|
||||
|
||||
1. Baseline-Version (z.B. `0.2.x`) normal installieren.
|
||||
2. Neues Release `v0.3.0` mit frischem Installer + App-Zip + Checksums veröffentlichen.
|
||||
3. App starten → Banner erscheint: `Update available: v0.2.x → v0.3.0`.
|
||||
4. **Update now** klicken → App schließt, Installer öffnet im Update-Mode, läuft, restartet Worker.
|
||||
5. App neu starten → Banner weg; `Help → Check for updates` zeigt kurz „You're up to date (v0.3.0)".
|
||||
6. `v0.2.x`-Installer manuell starten → bietet Self-Update auf v0.3.0 an. **Update** → laufende Exe wird ersetzt, Wizard öffnet auf neuer Version.
|
||||
7. Schritt 6 mit **Continue anyway** → Wizard öffnet ohne Self-Update.
|
||||
8. Schritt 6 mit **Cancel** → Installer beendet ohne Aktion.
|
||||
9. Network-Kill in App und Installer beim Start → silent fallback (kein Error, kein Banner).
|
||||
|
||||
---
|
||||
|
||||
## 2. UI-Polish (kritisch für Benutzbarkeit)
|
||||
## 2. UI-Polish
|
||||
|
||||
Im aktuellen Stand kompiliert die UI, aber mehrere Stellen sind als `// TODO` markiert. Reihenfolge nach Schmerz:
|
||||
|
||||
### 2.1 Folder-Picker für `Working Directory`
|
||||
- **Datei:** `src/ClaudeDo.Ui/Views/ListEditorView.axaml` + `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs`
|
||||
### 2.1 Folder-Picker für `Working Directory` ⬜
|
||||
- **Datei:** `Views/ListSettingsModalView.axaml` + zugehöriges VM
|
||||
- **Aktuell:** plain `TextBox` — Pfad muss getippt werden.
|
||||
- **Soll:** Button "…" daneben → öffnet `IStorageProvider.OpenFolderPickerAsync`, schreibt Pfad ins Feld.
|
||||
- **Aufwand:** klein, ~30 Zeilen.
|
||||
- **Soll:** Button „…" daneben → öffnet `IStorageProvider.OpenFolderPickerAsync`, schreibt Pfad ins Feld.
|
||||
- **Aufwand:** klein.
|
||||
|
||||
### 2.2 Delete-Confirmation
|
||||
- **Dateien:** `MainWindowViewModel.DeleteList`, `TaskListViewModel.DeleteTask`
|
||||
- **Aktuell:** löscht direkt ohne Rückfrage. Datenverlust-Risiko.
|
||||
- **Soll:** Mini-Dialog "Wirklich löschen?" mit Ja/Nein.
|
||||
- **Aufwand:** klein, generisches `ConfirmDialog` lohnt sich (1× bauen, mehrfach nutzen).
|
||||
### 2.2 Delete-Confirmation ⬜
|
||||
- **Aktuell:** Listen/Tasks-Delete läuft direkt ohne Rückfrage. Datenverlust-Risiko.
|
||||
- **Soll:** generischer `ConfirmDialog` (1× bauen, mehrfach nutzen), Mini-Dialog „Wirklich löschen?".
|
||||
- **Aufwand:** klein.
|
||||
|
||||
### 2.3 Markdown-Rendering für Result + Description
|
||||
- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml`
|
||||
- **Aktuell:** `TextBox IsReadOnly="True"` mit Plaintext.
|
||||
- **Soll:** `Markdown.Avalonia` Package einbinden und auf `MarkdownScrollViewer` umstellen.
|
||||
- **Aufwand:** mittel — Package + ein paar XAML-Anpassungen. Theme-Integration kann nerven.
|
||||
### 2.3 Markdown-Rendering Result + Description ✅
|
||||
- `Views/MarkdownView.axaml` + Detail-Pane verwenden Markdown.Avalonia.
|
||||
|
||||
### 2.4 Live-Log Auto-Scroll
|
||||
- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs` (oder im VM)
|
||||
- **Aktuell:** ndjson-Zeilen werden angehängt, aber Scrollposition bleibt stehen.
|
||||
- **Soll:** Bei jeder neuen Zeile `ScrollViewer.ScrollToEnd()` solange User nicht manuell hochgescrollt hat (Sticky-Bottom-Pattern).
|
||||
- **Aufwand:** klein, ein attached behavior reicht.
|
||||
### 2.4 Live-Log Auto-Scroll ⬜
|
||||
- **Datei:** `Views/DetailsIslandView.axaml(.cs)` (Live-Tail-Section)
|
||||
- **Aktuell:** ndjson-Zeilen werden angehängt, Scrollposition bleibt stehen.
|
||||
- **Soll:** Sticky-Bottom-Pattern — bei jeder neuen Zeile `ScrollToEnd()`, solange User nicht manuell hochgescrollt hat. Attached-Behavior reicht.
|
||||
|
||||
### 2.5 Diff-Viewer
|
||||
- **Datei:** `TaskDetailViewModel.ShowDiffAsync`
|
||||
- **Aktuell:** `Process.Start("cmd", "/k git diff …")` — separates Konsolenfenster, hässlich.
|
||||
- **Soll:** entweder unified-diff inline anzeigen (`git diff` Output in `TextBox` mit Mono-Font + Color für +/-) oder einen externen Diff-Tool-Hook (`git difftool`).
|
||||
- **Aufwand:** mittel. MVP: einfach nur den Diff-Output in einem Modal.
|
||||
### 2.5 Diff-Viewer 🟡
|
||||
- `DiffModalView.axaml` + `PlanningDiffView` existieren; integriert für Planning-Merges.
|
||||
- **Offen:** Task-Level-Diff (Worktree vs. main) noch nicht im Modal-Flow geprüft. Verwenden statt `Process.Start("cmd /k git diff …")`.
|
||||
|
||||
### 2.6 Status-Bar Active-Tasks Live-Update
|
||||
- **Datei:** `StatusBarViewModel`
|
||||
- **Risiko:** das Slot-State-Update kommt vom WorkerClient, aber `RunNowCommand.NotifyCanExecuteChanged` triggert nicht pro Item bei `IsConnected`-Wechsel (vom Slice-F-Agent dokumentiert).
|
||||
- **Soll:** Über `WeakReferenceMessenger` (CommunityToolkit.Mvvm) eine Connection-Change-Message verteilen, an die alle `TaskItemViewModel` lauschen.
|
||||
- **Aufwand:** klein, aber muss sauber gemacht werden.
|
||||
### 2.6 Status-Bar Active-Tasks Live-Update ⬜
|
||||
- **Datei:** `ViewModels/StatusBarViewModel`
|
||||
- **Risiko:** `RunNowCommand.NotifyCanExecuteChanged` triggert nicht pro Item bei Connection-Change.
|
||||
- **Soll:** `WeakReferenceMessenger`-Connection-Change-Message; alle `TaskRowViewModel` lauschen.
|
||||
- **Aufwand:** klein, muss sauber gemacht werden.
|
||||
|
||||
### 2.7 Settings-Dialog
|
||||
- **Datei:** *neu* — `Views/SettingsDialog.axaml` + VM
|
||||
- **Aktuell:** `~/.todo-app/ui.config.json` muss von Hand editiert werden.
|
||||
- **Soll:** Dialog mit Feldern: DB-Pfad, SignalR-Port, Default-Tags. Persistiert zurück in JSON.
|
||||
- **Aufwand:** mittel. Achtung: Port-Wechsel braucht Worker-Restart.
|
||||
### 2.7 Settings-Dialog ✅
|
||||
- `SettingsModalView` als `TabControl`, Tabs: General, Prime Claude, etc. Persistiert in `~/.todo-app/ui.config.json` und `worker.config.json`.
|
||||
|
||||
### 2.8 (NEU) Planning-Phase Badge-Farbe für `Finalized` ⬜
|
||||
Siehe §1.1 — `planning`-Klasse bleibt amber, blauer `planned`-Style nicht angewendet.
|
||||
|
||||
### 2.9 (NEU) Tote Converter-Statics entfernen ⬜
|
||||
`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance` — siehe §1.1.
|
||||
|
||||
---
|
||||
|
||||
## 3. Worker-Robustheit
|
||||
|
||||
### 3.1 CLI-Preflight beim Worker-Start
|
||||
- **Datei:** `src/ClaudeDo.Worker/Program.cs`
|
||||
- **Soll:** vor `app.Run()` `claude --version` ausführen; bei Fehler `app.Logger.LogCritical` + `Environment.Exit(1)`.
|
||||
- **Aufwand:** klein, ~20 Zeilen. Liefert Verification Step 2.
|
||||
### 3.1 CLI-Preflight beim Worker-Start ✅
|
||||
- `src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs` + Wiring in `Program.cs`. Tests: `tests/.../Lifecycle/ClaudeCliPreflightTests.cs`. Skippable via `CLAUDEDO_SKIP_CLI_PREFLIGHT=1`.
|
||||
|
||||
### 3.2 Worktree-Cleanup beim Anlege-Failed
|
||||
### 3.2 Worktree-Cleanup beim Anlege-Failed ⬜
|
||||
- **Datei:** `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`
|
||||
- **Aktuell:** Wenn `WorktreeAddAsync` zwischen `CreateAsync`-Schritten failt (z.B. Branch existiert schon), bleibt evtl. ein halbangelegter Worktree-Dir auf der Platte.
|
||||
- **Soll:** try/finally — bei Fehler `git worktree remove --force` als Best-Effort-Cleanup.
|
||||
- **Soll:** try/finally — bei Fehler zwischen `git worktree add` und DB-Insert `git worktree remove --force` als Best-Effort-Cleanup.
|
||||
- **Aufwand:** klein.
|
||||
|
||||
### 3.3 Logging über `Microsoft.Extensions.Logging` strukturieren
|
||||
- **Datei:** alle Worker-Komponenten
|
||||
- **Aktuell:** ILogger wird benutzt, aber kein File-Sink konfiguriert.
|
||||
- **Soll:** Optional Serilog oder einfach `AddFile` (Karambolage.Extensions.Logging.File) — Service-Modus braucht persistente Logs außerhalb der Console.
|
||||
### 3.3 Logging über file-Sink ⬜
|
||||
- ILogger ist überall verdrahtet, aber kein File-Sink konfiguriert.
|
||||
- **Soll:** Serilog oder `Karambolage.Extensions.Logging.File` — für Service-Modus zwingend, console-only ist im SCM-Fenster verloren.
|
||||
- **Aufwand:** klein.
|
||||
|
||||
### 3.4 Tag-Negation / Exclusion (Plan-TODO)
|
||||
- **Plan-Sektion:** "Tag-Modell"
|
||||
- **Aktuell:** Tags sind rein additiv (`list_tags ∪ task_tags`).
|
||||
- **Soll:** Mechanismus, um auf Task-Ebene einen List-Tag auszuschließen. Z.B. neue Tabelle `task_tag_exclusions` ODER ein Prefix `!tag` im task_tags-Eintrag.
|
||||
### 3.4 Tag-Negation / Exclusion ⬜
|
||||
- Tags sind weiterhin rein additiv (`list_tags ∪ task_tags`). Nach Slice „Editierbare Tags" weniger dringend, aber nicht gelöst.
|
||||
- **Soll:** entweder neue Tabelle `task_tag_exclusions` oder Prefix `!tag` im task_tags-Eintrag.
|
||||
- **Aufwand:** mittel — Schema + Repo + Tests + UI.
|
||||
|
||||
---
|
||||
|
||||
## 4. Service-Deployment (Plan-Sektion „Worker als Windows-Service")
|
||||
## 4. Service-Deployment
|
||||
|
||||
### 4.1 Windows-Service-Hosting in Code
|
||||
- **Datei:** `src/ClaudeDo.Worker/Program.cs`
|
||||
- **Pakete:** `Microsoft.Extensions.Hosting.WindowsServices`
|
||||
- **Soll:**
|
||||
```csharp
|
||||
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
|
||||
builder.Logging.AddEventLog(...);
|
||||
```
|
||||
- **Aufwand:** klein.
|
||||
### 4.1 Windows-Service-Hosting ✅
|
||||
- `Program.cs` ruft `builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker")`.
|
||||
|
||||
### 4.2 Pfad-Auflösung absolut machen
|
||||
- Bereits in `WorkerConfig.Load` per `Paths.Expand` gemacht — verifizieren, dass auch `cfg.ClaudeBin` ggf. in Service-PATH gefunden wird.
|
||||
### 4.2 Pfad-Auflösung absolut ✅
|
||||
- `WorkerConfig.Load` expandiert `~`/`%USERPROFILE%` für alle Pfad-Felder.
|
||||
|
||||
### 4.3 Install-Skripte / Doku
|
||||
- **Datei:** *neu* — `docs/install-service.md` oder `scripts/install-service.cmd`
|
||||
### 4.3 Install-Skripte / Doku ⬜
|
||||
- **Datei (neu):** `docs/install-service.md` ODER `scripts/install-service.cmd`
|
||||
- **Inhalt:** `dotnet publish` + `sc.exe create` + `sc.exe failure` + Hinweis auf `obj=` (User-Account) wegen Claude-CLI-Session.
|
||||
- **Aufwand:** klein.
|
||||
|
||||
### 4.4 (später) Installer-Projekt
|
||||
- WiX/MSIX, registriert Service + UI-Shortcut. Plan-Sektion „Offene Punkte".
|
||||
### 4.4 Installer-Projekt ✅
|
||||
- `ClaudeDo.Installer` (WPF) + `ClaudeDo.Releases` mit Pages/Steps/Core/Theme — Self-Update funktioniert (siehe §1.3).
|
||||
|
||||
---
|
||||
|
||||
## 5. Tests / CI
|
||||
|
||||
### 5.1 GitHub-Actions / Gitea-Actions Pipeline
|
||||
- **Datei:** *neu* — `.gitea/workflows/ci.yml` (oder `.github/workflows/ci.yml`)
|
||||
- **Inhalt:** `dotnet restore` → `dotnet build --no-restore` → `dotnet test --no-build`. Auf Push + PR.
|
||||
### 5.1 CI-Pipeline (Gitea Actions) ⬜
|
||||
- **Datei (neu):** `.gitea/workflows/ci.yml`
|
||||
- **Inhalt:** `dotnet restore` → `dotnet build` (csproj-weise wegen `.slnx`-Bug auf .NET 8) → `dotnet test`. Auf Push + PR.
|
||||
- **Achtung:** Pipeline darf NICHT die `.slnx` als Build-Target nehmen — explizite csproj-Liste in einem checked-in Build-Skript.
|
||||
- **Aufwand:** klein.
|
||||
|
||||
### 5.2 Echter SignalR-Roundtrip-Test
|
||||
- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Hub/WorkerHubTests.cs`
|
||||
- **Soll:** mit `WebApplicationFactory` + `HubConnectionBuilder` testen, dass `Ping`, `GetActive`, `RunNow`-Throw-Verhalten korrekt sind. Plan-Verification 1b + 9.
|
||||
- **Aufwand:** mittel.
|
||||
### 5.2 SignalR-Hub-Tests ✅
|
||||
- `tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs`, `AgentSettingsHubTests.cs` testen Hub-Methoden via Fakes (kein realer SignalR-Roundtrip, aber alle Code-Pfade abgedeckt).
|
||||
- **Optional verbleibt:** echter Roundtrip-Test mit `WebApplicationFactory<Program>` + `HubConnectionBuilder` für End-to-End-Validierung der SignalR-Pipeline. Niedriger Mehrwert solange Fakes alle Methoden treffen.
|
||||
|
||||
### 5.3 Smoke-Test gegen echten `claude`
|
||||
- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs`
|
||||
- **Soll:** Real-CLI-Test, der mit `[Fact(Skip="..."]` ausgegraut bleibt und nur lokal aktiviert wird, wenn `CLAUDE_AUTHENTICATED=1` Env-Var gesetzt ist.
|
||||
### 5.3 Smoke-Test gegen echten `claude` ⬜
|
||||
- **Datei (neu):** `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs`
|
||||
- **Soll:** Real-CLI-Test mit `[Fact(Skip="...")]` ausgegraut, nur lokal aktiviert wenn `CLAUDE_AUTHENTICATED=1` Env-Var gesetzt ist.
|
||||
- **Aufwand:** klein.
|
||||
|
||||
### 5.4 (NEU) ExternalMcpService-Tests ⬜
|
||||
- `External/ExternalMcpService` hat 11 Tools, aber nur partielle Coverage in `tests/.../External/ExternalMcpServiceTests.cs`. Für jedes Tool mindestens einen Happy-Path + einen Error-Pfad ergänzen.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dokumentation
|
||||
|
||||
### 6.1 README.md
|
||||
- Komplett fehlt. Mind. 1× kurz: was ist es, wie starten (Worker + UI), wo Config.
|
||||
- **Aufwand:** klein.
|
||||
### 6.1 README.md ⬜
|
||||
- Komplett fehlt. Mind. 1× kurz: was ist es, wie starten (Worker + UI), wo Config, wie Self-Update.
|
||||
|
||||
### 6.2 `docs/architecture.md`
|
||||
- In `plan.md` schon teilweise enthalten — kann entweder konsolidiert oder explizit ausgegliedert werden.
|
||||
### 6.2 docs/architecture.md 🟡
|
||||
- In `plan.md` enthalten — entweder konsolidieren oder explizit ausgliedern. CLAUDE.md-Dateien pro Projekt sind aktuell de-facto-Architecture-Doc.
|
||||
|
||||
### 6.3 ADRs für die getroffenen Entscheidungen
|
||||
- Z.B. „SignalR vs. SQLite-Polling für IPC", „Worktree pro Task", „SignalR über Loopback ohne Auth".
|
||||
### 6.3 ADRs ⬜
|
||||
- Vorschläge: „SignalR vs. SQLite-Polling für IPC", „Worktree pro Task", „TaskStateService als alleiniger State-Owner", „BlockedByTaskId statt Status='Waiting'", „External MCP als zweite WebApplication".
|
||||
- **Aufwand:** klein, hilfreich für später.
|
||||
|
||||
### 6.4 (NEU) Mailbox-Proposal ⬜
|
||||
- `docs/mailbox-proposal.md` ist als Vorschlag vorhanden, nicht implementiert. Entscheidung: bauen, droppen oder parken? Wenn droppen → Datei entfernen, sonst klare Roadmap.
|
||||
|
||||
---
|
||||
|
||||
## 7. Bekannte Code-Schulden / Smells
|
||||
|
||||
| Stelle | Issue |
|
||||
|---|---|
|
||||
| `WorkerHub.GetActive` returnt `IReadOnlyList<object>` mit anonymen Typen | Sollte ein expliziter DTO sein (`ActiveTaskDto`), den Worker UND Ui teilen. Aktuell duplizieren beide das Schema. |
|
||||
| `TaskRunner` führt eine `if (list.WorkingDir != null)` Verzweigung mitten in der Methode | Strategy-Pattern (`IRunStrategy`: SandboxStrategy, WorktreeStrategy) wenn die Methode wächst. Aktuell noch klein genug. |
|
||||
| `App.Services` als public static `ServiceProvider` | Service-Locator-Antipattern. Toleriert, weil nur in `App.OnFrameworkInitializationCompleted` verwendet. Falls mehr Code drauf zugreift → echtes DI durchziehen. |
|
||||
| Embedded `schema.sql` ohne Versionierung | Solange das Schema nicht in Production läuft, OK. Sobald User-Daten existieren → `migrations/` Folder + Version-Tabelle. |
|
||||
| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer. |
|
||||
| Stelle | Issue | Status |
|
||||
|---|---|---|
|
||||
| `WorkerHub.GetActive` returnt `IReadOnlyList<object>` mit anonymen Typen | Sollte expliziter `ActiveTaskDto` sein, den Worker UND UI teilen | ⬜ |
|
||||
| `TaskRunner` führt eine `if (list.WorkingDir != null)`-Verzweigung mitten in der Methode | Strategy-Pattern wenn die Methode wächst, aktuell noch klein genug | ⬜ |
|
||||
| `App.Services` als public static `ServiceProvider` | Service-Locator-Antipattern, toleriert weil nur in `App.OnFrameworkInitializationCompleted` | ⬜ |
|
||||
| Embedded `schema.sql` ohne Versionierung | Durch EF-Core-Migrationen ersetzt | ✅ |
|
||||
| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer | ⬜ |
|
||||
| Tote Converter-Instances (`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance`) | Per Resource-Dictionary registriert, Statics ungenutzt | ⬜ |
|
||||
| 1 unausgeführter `// TODO` in `DetailsIslandViewModel` (`SendPromptAsync` ohne Hub-Methode) | Entweder Hub-Methode bauen oder TODO entfernen | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Reihenfolge für die nächste Session
|
||||
## 8. Improvement Plan (improvement-plan.md, Stand 2026-04-13)
|
||||
|
||||
1. **Verification Step 4** zusammen durchspielen → falls etwas grundlegend kaputt ist, jetzt finden, nicht später.
|
||||
2. **CLI-Preflight (3.1)** + **Folder-Picker (2.1)** + **Delete-Confirm (2.2)** — kleine, isolierte Wins.
|
||||
3. **Auto-Scroll (2.4)** + **Active-Tasks Live-Update (2.6)** — User-Experience im Detail-Pane.
|
||||
4. **Markdown-Rendering (2.3)** — größer, lohnt sich aber für Lesbarkeit.
|
||||
5. **Worktree-Cleanup (3.2)** — Robustheit, bevor wir Worktrees ernsthaft nutzen.
|
||||
6. **CI-Pipeline (5.1)** — automatisches Sicherheitsnetz für alles weitere.
|
||||
7. **Service-Deployment (4)** — wenn die App lokal stabil läuft.
|
||||
8. **Settings-Dialog (2.7)** + **Diff-Viewer (2.5)** — Polish.
|
||||
9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird.
|
||||
|
||||
Punkte 1–3 sind ein realistischer Block für eine Session.
|
||||
| ID | Item | Status | Bemerkung |
|
||||
|---|---|---|---|
|
||||
| IP-1 | UI ↔ Worker Auto-Reconnect | ✅ | `WorkerClient` mit `WithAutomaticReconnect()` + Reconnect-Handler |
|
||||
| IP-2 | Listen-Modus „Notes" (non-autonomous) | ⬜ | Nach Slice „editierbare Status/Tags" weniger dringend (man kann jetzt einen Task ohne `agent`-Tag idle lassen), aber `lists.kind` als sauberer Mode-Switch fehlt. |
|
||||
| IP-3 | Doppelklick öffnet Edit-Dialog | ✅ | `DoubleTapped`-Handler in `ListsIslandView`, `TasksIslandView` |
|
||||
| IP-4 | Tag Multi-Select Control | ⬜ | Tags sind via Picker im Detail-Pane editierbar, aber kein dediziertes Multi-Select-Control mit Auto-Vervollständigung in Editor-Dialogen. |
|
||||
| IP-5 | Rechtsklick-Kontextmenü | ✅ | Listen + Tasks haben Context-Menüs (Edit, Delete, Run Now, Show Diff, Merge, Cancel, Status, Tags) |
|
||||
| IP-6 | Schema-Migration-Mechanismus | ✅ | EF-Core-Migrations + `__EFMigrationsHistory` |
|
||||
| IP-7 | Status-Bar Reconnect-States | ✅ | `connected`/`connecting`/`reconnecting`/`offline` farbcodiert |
|
||||
| IP-8 | Tag-Repository `GetAllKnownTagsAsync` | ✅ | `TagRepository.GetAllAsync` + `WorkerClient.GetAllTagsAsync` |
|
||||
|
||||
---
|
||||
|
||||
## Self-Update — Manual Verification
|
||||
## 9. Empfohlene Reihenfolge für die nächsten Sessions
|
||||
|
||||
Preconditions: a working Gitea release at `git.kuns.dev/releases/ClaudeDo` with three assets — `ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, and `checksums.txt` listing both.
|
||||
**Block 1 — Verification durchspielen** (kein neuer Code, nur Beweis):
|
||||
1. §1.0 Steps 3–7 manuell (Smoke + E2E + Worktree + No-Changes + Kein-Repo) — ist die Pipeline wirklich lebendig?
|
||||
2. §1.1 Planning-Walkthrough — nach den uncommitted Coordinator-Änderungen einmal durchspielen.
|
||||
3. §1.2 Prime-Walkthrough — Schedule-Trigger einmal beobachten.
|
||||
|
||||
1. Install a baseline version (e.g. `0.2.x`) normally.
|
||||
2. Publish a new release tagged `v0.3.0` with fresh installer + app zip + checksums.
|
||||
3. Launch the app — confirm the banner appears: `Update available: v0.2.x → v0.3.0`.
|
||||
4. Click **Update now** — app closes, installer opens in Update mode, runs, restarts the worker.
|
||||
5. Re-launch the app — banner is gone; `Help → Check for updates` briefly shows "You're up to date (v0.3.0)".
|
||||
6. Run the `v0.2.x` installer manually — confirm it prompts to self-update to v0.3.0. Click **Update** → running exe is replaced and the wizard opens on the new version.
|
||||
7. Repeat step 6 with **Continue anyway** → wizard opens without self-update.
|
||||
8. Repeat step 6 with **Cancel** → installer exits without any action.
|
||||
9. Kill network during startup in both app and installer → confirm silent fallback (no errors, no banner, wizard opens normally).
|
||||
**Block 2 — Niedrig hängende UI-Polish** (eine Session):
|
||||
4. §2.1 Folder-Picker
|
||||
5. §2.2 Delete-Confirmation
|
||||
6. §2.4 Live-Log Auto-Scroll
|
||||
7. §2.6 Status-Bar Live-Update
|
||||
8. §2.8 Planning-Badge-Farbe + §2.9 tote Converter weg
|
||||
|
||||
---
|
||||
**Block 3 — Robustheit & Service-Deployment**:
|
||||
9. §3.2 Worktree-Cleanup
|
||||
10. §3.3 File-Sink-Logging
|
||||
11. §4.3 Install-Skripte/Doku
|
||||
|
||||
## Planning Sessions — Manual Verification (Plan C UI)
|
||||
**Block 4 — Sicherheitsnetz**:
|
||||
12. §5.1 Gitea-Actions CI-Pipeline (csproj-weise)
|
||||
13. §5.3 Smoke-Test gegen echten claude
|
||||
14. §5.4 ExternalMcpService-Tests vervollständigen
|
||||
|
||||
Requires Plan B (worker hub endpoints) merged. Until then, only the UI structure/styling checks are meaningful.
|
||||
**Block 5 — Dokumentation & Aufräumen**:
|
||||
15. §6.1 README
|
||||
16. §6.3 ADRs (mind. die fünf wichtigsten)
|
||||
17. §6.4 Mailbox-Proposal: bauen/droppen entscheiden
|
||||
18. §7 Smells: `ActiveTaskDto`, `.gitattributes`, TODO-Comment
|
||||
|
||||
1. Create a Manual task with a title and a TODO-ish description.
|
||||
2. Right-click the task → **Open planning Session** — Windows Terminal opens with Claude CLI running (Plan B).
|
||||
3. Ask Claude to create two child tasks via `mcp__claudedo__create_child_task`.
|
||||
4. Watch the UI: drafts appear indented under the parent, italic, reduced opacity, with a `DRAFT` badge.
|
||||
5. The parent shows a `PLANNING` badge. Click the chevron → children collapse; click again → children expand.
|
||||
6. Ask Claude to `finalize` — drafts flip to Manual/Queued children; parent flips to `PLANNED` badge.
|
||||
7. In a new planning task, close the terminal without finalize. Right-click the Planning task → the unfinished-session modal opens with Resume / Finalize now / Discard.
|
||||
8. Attempt to delete a parent with children via the details panel — confirm the friendly error dialog appears and the task is NOT deleted.
|
||||
|
||||
**Known followups (non-blocking):**
|
||||
- `Border.badge.planned` style (blue) is defined in `IslandStyles.axaml` but never applied — `TaskRowView` keeps the `planning` class for both Planning and Planned, so Planned gets the amber badge. Either make the view swap `Classes.planned` when status is Planned, or remove the unused style + brush.
|
||||
- Dead `Instance` statics on `BoolToItalicConverter` and `BoolToDraftOpacityConverter` — App.axaml registers instances via the resource dictionary; the static members can be removed.
|
||||
**Block 6 — Optional / wenn Bedarf konkret wird**:
|
||||
19. §3.4 Tag-Negation
|
||||
20. §IP-2 Notes-Modus
|
||||
21. §IP-4 Tag Multi-Select Control
|
||||
|
||||
@@ -49,7 +49,9 @@ Schema in 3NF. Keine Mehrwert-Felder (z.B. JSON-Arrays), keine transitiven Abhä
|
||||
- `list_id` TEXT NOT NULL REFERENCES `lists(id)` ON DELETE CASCADE
|
||||
- `title` TEXT NOT NULL
|
||||
- `description` TEXT NULL
|
||||
- `status` TEXT NOT NULL — `manual` | `queued` | `running` | `done` | `failed` (`running` bleibt persistiert für Crash-Recovery: stale `running`-Tasks werden beim Worker-Start auf `failed` gesetzt)
|
||||
- `status` TEXT NOT NULL — Lifecycle-only: `idle` | `queued` | `running` | `done` | `failed` | `cancelled` (`running` bleibt persistiert für Crash-Recovery: stale `running`-Tasks werden beim Worker-Start auf `failed` gesetzt). Planungs-Hierarchie und Chain-Blocking laufen über zwei separate Felder.
|
||||
- `planning_phase` TEXT NOT NULL DEFAULT `'none'` — Parent-only Marker: `none` | `active` (Planung läuft) | `finalized` (Plan committed, Children existieren). Ein Parent kann `status='idle'` sein und gleichzeitig `planning_phase='finalized'` (für Re-Runs).
|
||||
- `blocked_by_task_id` TEXT NULL REFERENCES `tasks(id)` ON DELETE SET NULL — Vorgänger in einem sequenziellen Subtask-Chain. Ein `queued`-Row mit `blocked_by_task_id IS NOT NULL` wird vom Picker übersprungen.
|
||||
- `scheduled_for` TIMESTAMP NULL — "nicht vor"
|
||||
- `result` TEXT NULL (Markdown)
|
||||
- `log_path` TEXT NULL — Pfad zur ndjson-Log-Datei
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
|
||||
|
||||
|
||||
# ClaudeDo — Prompt & CLI Inventory
|
||||
|
||||
Snapshot of every string ClaudeDo sends to Claude CLI, plus the CLI-flag surface that shapes each run. Intended as a working doc for tomorrow's prompt-tuning pass.
|
||||
|
||||
@@ -0,0 +1,897 @@
|
||||
# External MCP — CRUD Extensions 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:** Extend the always-on `ExternalMcpService` with full task CRUD plus tag management so a normal Claude CLI session can fully manage scope-creep tasks via MCP.
|
||||
|
||||
**Architecture:** Pure extension of the existing service. New repository helper for tag replacement; five new/extended `[McpServerTool]` methods. No DI changes (`TagRepository` is already registered for `TaskRepository`/`ListRepository`). Uses the same `X-ClaudeDo-Key` middleware already in place.
|
||||
|
||||
**Tech Stack:** .NET 8, EF Core (SQLite), `ModelContextProtocol.Server` (MCP SDK), xUnit.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight
|
||||
|
||||
The test assembly `tests/ClaudeDo.Worker.Tests` currently fails to compile on `main` because of pre-existing in-progress work on `PlanningChainCoordinator` (stale `TaskRunner` / `WorkerHub` constructor calls in `QueueServiceTests.cs`, `QueueServiceSlotGuardTests.cs`, `PlanningHubTests.cs`). This is unrelated to this work and must NOT be fixed here.
|
||||
|
||||
Consequence: `dotnet test` cannot execute until that refactor lands. Each task's "Run test, verify it fails" step uses `dotnet build` of the **test csproj** to confirm only the new test's compile expectations, and `dotnet build` of the **production csproj** to confirm production code is correct. When the refactor lands, the engineer or user re-runs `dotnet test --filter "FullyQualifiedName~ExternalMcpServiceTests"` to validate the new tests for real.
|
||||
|
||||
Build commands used throughout (per the project memory note "use csproj, not .slnx, on .NET 8"):
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/ClaudeDo.Data/Repositories/TaskRepository.cs` | Add `SetTagsAsync` (replace tag set, auto-create rows) |
|
||||
| `src/ClaudeDo.Worker/External/ExternalMcpService.cs` | Inject `TagRepository`; extend `AddTask` with `tags`; add `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags` |
|
||||
| `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` | New test file with fakes mirroring `Planning/PlanningMcpServiceTests.cs` |
|
||||
|
||||
`TagRepository.GetAllAsync` already exists — no change needed there.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `TaskRepository.SetTagsAsync`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (add new method inside the `#region Tags` block, after `RemoveTagAsync`)
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` (use the existing `MakeTask`/list-seed helpers from that file — match the pattern used in adjacent tests):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task SetTagsAsync_AttachesNewTagsAndCreatesMissingRows()
|
||||
{
|
||||
var listId = await CreateListAsync("L");
|
||||
var task = MakeTask(listId, "t");
|
||||
await _tasks.AddAsync(task);
|
||||
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "novel-tag" });
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
||||
Assert.Contains(tags, t => t.Name == "agent");
|
||||
Assert.Contains(tags, t => t.Name == "novel-tag");
|
||||
Assert.Equal(2, tags.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTagsAsync_ReplacesExistingTagSet()
|
||||
{
|
||||
var listId = await CreateListAsync("L");
|
||||
var task = MakeTask(listId, "t");
|
||||
await _tasks.AddAsync(task);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "manual" });
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
||||
Assert.Single(tags);
|
||||
Assert.Equal("manual", tags[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTagsAsync_DeduplicatesCaseInsensitively()
|
||||
{
|
||||
var listId = await CreateListAsync("L");
|
||||
var task = MakeTask(listId, "t");
|
||||
await _tasks.AddAsync(task);
|
||||
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "AGENT", "Agent" });
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
||||
Assert.Single(tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTagsAsync_EmptyListClearsAllTags()
|
||||
{
|
||||
var listId = await CreateListAsync("L");
|
||||
var task = MakeTask(listId, "t");
|
||||
await _tasks.AddAsync(task);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
|
||||
await _tasks.SetTagsAsync(task.Id, Array.Empty<string>());
|
||||
|
||||
Assert.Empty(await _tasks.GetTagsAsync(task.Id));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
|
||||
```
|
||||
Expected: compile error `CS1061: 'TaskRepository' does not contain a definition for 'SetTagsAsync'`. (Existing unrelated `CS7036` errors from `PlanningChainCoordinator` work also appear — ignore.)
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, inside `#region Tags`, after `RemoveTagAsync`:
|
||||
|
||||
```csharp
|
||||
public async Task SetTagsAsync(string taskId, IReadOnlyList<string> tagNames, CancellationToken ct = default)
|
||||
{
|
||||
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||
if (task is null) return;
|
||||
|
||||
task.Tags.Clear();
|
||||
|
||||
foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
|
||||
if (tag is null)
|
||||
{
|
||||
tag = new TagEntity { Name = name };
|
||||
_context.Tags.Add(tag);
|
||||
}
|
||||
task.Tags.Add(tag);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it compiles + production build still passes**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
||||
```
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "TaskRepositoryTests" || echo "no errors in TaskRepositoryTests"
|
||||
```
|
||||
Expected: no errors specific to `TaskRepositoryTests` (assembly may still fail due to unrelated `PlanningChainCoordinator` issues).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs
|
||||
git commit -m "feat(data): add TaskRepository.SetTagsAsync for full tag-set replacement"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: New test file scaffolding for `ExternalMcpService`
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||
|
||||
This task creates the shared test fakes and one trivial passing test. Subsequent tasks reuse the same fakes.
|
||||
|
||||
- [ ] **Step 1: Inspect existing patterns**
|
||||
|
||||
Read `tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs` for the `FakeHubContext`/`RecordingClientProxy` pattern and `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs` for how to construct a real `QueueService` for tests (the same approach is used here — `ExternalMcpService` depends on it for `WakeQueue`/`RunNow`/`CancelTask`).
|
||||
|
||||
- [ ] **Step 2: Write the test scaffolding**
|
||||
|
||||
Create `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Services;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
file sealed class RecordingHubClients : IHubClients
|
||||
{
|
||||
public RecordingClientProxy Proxy { get; } = new();
|
||||
public IClientProxy All => Proxy;
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||
public IClientProxy Client(string connectionId) => Proxy;
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
|
||||
public IClientProxy Group(string groupName) => Proxy;
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
|
||||
public IClientProxy User(string userId) => Proxy;
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
|
||||
}
|
||||
|
||||
file sealed class RecordingClientProxy : IClientProxy
|
||||
{
|
||||
public List<(string Method, object?[] Args)> Calls { get; } = new();
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Calls.Add((method, args));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeHubContext : IHubContext<WorkerHub>
|
||||
{
|
||||
public RecordingHubClients RecordingClients { get; } = new();
|
||||
public IHubClients Clients => RecordingClients;
|
||||
public IGroupManager Groups => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public sealed class ExternalMcpServiceTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TagRepository _tags;
|
||||
private readonly FakeHubContext _hub;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
|
||||
public ExternalMcpServiceTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
_tags = new TagRepository(_ctx);
|
||||
_hub = new FakeHubContext();
|
||||
_broadcaster = new HubBroadcaster(_hub);
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private async Task<string> SeedListAsync(string name = "L")
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow });
|
||||
return id;
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Manual)
|
||||
{
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = title,
|
||||
Status = status,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
// QueueService is needed by ExternalMcpService's constructor. For tests that
|
||||
// only exercise UpdateTask / DeleteTask / SetTaskTags / ListTags / ListTags,
|
||||
// we never call its WakeQueue/RunNow/CancelTask paths, so a real QueueService
|
||||
// built with the same approach used in QueueServiceTests is sufficient.
|
||||
private ExternalMcpService BuildSut(QueueService queue) =>
|
||||
new(_tasks, _lists, queue, _broadcaster, _tags);
|
||||
|
||||
[Fact]
|
||||
public async Task SeededListAndTask_AreRetrievable()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
Assert.NotNull(await _tasks.GetByIdAsync(task.Id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The trivial `SeededListAndTask_AreRetrievable` test exists to confirm the scaffolding compiles and the fakes work, without depending on `ExternalMcpService` itself yet.
|
||||
|
||||
Note: `BuildSut` uses a 5-argument constructor signature that does not exist yet — this matches the future signature added in Task 3. The compiler will accept this method only after Task 3.
|
||||
|
||||
- [ ] **Step 3: Verify the file references resolve**
|
||||
|
||||
Build the test csproj and check for errors specific to `ExternalMcpServiceTests`:
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "ExternalMcpServiceTests"
|
||||
```
|
||||
Expected output: only one error referring to the 5-arg `ExternalMcpService` constructor (resolved in Task 3). No missing-namespace or syntax errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||
git commit -m "test(external): scaffold ExternalMcpServiceTests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Inject `TagRepository` into `ExternalMcpService` + add `ListTags`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||
|
||||
Smallest possible change to unblock everything else: take the new dependency and ship the simplest tool first.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`. The `BuildSut` helper is defined in Task 2; tests construct `QueueService` the same way `QueueServiceTests.cs` does (look there for the exact constructor argument list and adopt it verbatim):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ListTags_ReturnsSeededAndCustomTags()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "custom-tag" });
|
||||
|
||||
using var queue = QueueServiceFactory.Create(_ctx, _broadcaster); // see helper note below
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var tags = await sut.ListTags(CancellationToken.None);
|
||||
|
||||
Assert.Contains(tags, t => t.Name == "agent");
|
||||
Assert.Contains(tags, t => t.Name == "custom-tag");
|
||||
}
|
||||
```
|
||||
|
||||
If a `QueueServiceFactory` helper does not already exist in the test project, inline the construction by mirroring the setup found in `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs` (it builds `QueueService` directly with `IDbContextFactory`, `HubBroadcaster`, fake claude process, etc.). Do NOT call `StartAsync`; just construct and dispose.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep -E "ExternalMcpService|ExternalMcpServiceTests"
|
||||
```
|
||||
Expected: errors about the 5-arg constructor and `ListTags` not existing.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `src/ClaudeDo.Worker/External/ExternalMcpService.cs`:
|
||||
|
||||
1. Add `TagRepository` field and constructor parameter:
|
||||
|
||||
```csharp
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly QueueService _queue;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly TagRepository _tags;
|
||||
|
||||
public ExternalMcpService(
|
||||
TaskRepository tasks,
|
||||
ListRepository lists,
|
||||
QueueService queue,
|
||||
HubBroadcaster broadcaster,
|
||||
TagRepository tags)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_lists = lists;
|
||||
_queue = queue;
|
||||
_broadcaster = broadcaster;
|
||||
_tags = tags;
|
||||
}
|
||||
```
|
||||
|
||||
2. Add a tag DTO above the class (next to `TaskListDto`):
|
||||
|
||||
```csharp
|
||||
public sealed record TagDto(long Id, string Name);
|
||||
```
|
||||
|
||||
3. Add the new tool method (place at the end of the class, before `ToDto`):
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("List all known tags. Useful for discovering existing tag names (including 'agent' which marks tasks for auto-execution) before tagging.")]
|
||||
public async Task<IReadOnlyList<TagDto>> ListTags(CancellationToken cancellationToken)
|
||||
{
|
||||
var tags = await _tags.GetAllAsync(cancellationToken);
|
||||
return tags.Select(t => new TagDto(t.Id, t.Name)).ToList();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify production build + new test compiles**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
```
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "ExternalMcpService"
|
||||
```
|
||||
Expected: no errors mentioning `ExternalMcpService` or `ListTags`. (Unrelated `PlanningChainCoordinator` errors persist.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||
git commit -m "feat(mcp/external): add ListTags + inject TagRepository"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Extend `AddTask` to accept `tags`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs` (`AddTask` method)
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `ExternalMcpServiceTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task AddTask_WithTags_AttachesTags()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
using var queue = /* same construction as ListTags test */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.AddTask(
|
||||
listId, "scope-creep handoff", "desc", "claude-cli",
|
||||
queueImmediately: false,
|
||||
tags: new[] { "agent", "custom" },
|
||||
CancellationToken.None);
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(dto.Id);
|
||||
Assert.Contains(tags, t => t.Name == "agent");
|
||||
Assert.Contains(tags, t => t.Name == "custom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTask_NullTags_BehavesAsBefore()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.AddTask(
|
||||
listId, "no tags", null, "claude-cli",
|
||||
queueImmediately: false, tags: null, CancellationToken.None);
|
||||
|
||||
Assert.Empty(await _tasks.GetTagsAsync(dto.Id));
|
||||
}
|
||||
```
|
||||
|
||||
(Replace the `/* same construction */` placeholder with the actual `QueueService` construction used in Task 3 — repeat the code, do not extract.)
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "AddTask"
|
||||
```
|
||||
Expected: error that `AddTask` does not accept a 7th `tags` parameter.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Replace the existing `AddTask` method in `ExternalMcpService.cs` with:
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. Optional tags are attached on creation; missing tag names auto-create.")]
|
||||
public async Task<TaskDto> AddTask(
|
||||
string listId,
|
||||
string title,
|
||||
string? description,
|
||||
string createdBy,
|
||||
bool queueImmediately,
|
||||
IReadOnlyList<string>? tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(listId))
|
||||
throw new InvalidOperationException("listId is required.");
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
throw new InvalidOperationException("title is required.");
|
||||
if (string.IsNullOrWhiteSpace(createdBy))
|
||||
throw new InvalidOperationException("createdBy is required.");
|
||||
|
||||
var list = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
|
||||
var entity = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = title,
|
||||
Description = description,
|
||||
Status = queueImmediately ? TaskStatus.Queued : TaskStatus.Manual,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = list.DefaultCommitType,
|
||||
CreatedBy = createdBy,
|
||||
};
|
||||
await _tasks.AddAsync(entity, cancellationToken);
|
||||
|
||||
if (tags is not null && tags.Count > 0)
|
||||
await _tasks.SetTagsAsync(entity.Id, tags, cancellationToken);
|
||||
|
||||
if (queueImmediately)
|
||||
_queue.WakeQueue();
|
||||
|
||||
await _broadcaster.TaskUpdated(entity.Id);
|
||||
return ToDto(entity);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify production build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
```
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||
git commit -m "feat(mcp/external): AddTask accepts tags on creation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `UpdateTask`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `ExternalMcpServiceTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task UpdateTask_PatchesNonNullFieldsOnly()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, "old title");
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("new title", dto.Title);
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal("new title", loaded!.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_TagsReplaceFullSet()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await sut.UpdateTask(task.Id, null, null, null, new[] { "manual" }, CancellationToken.None);
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
||||
Assert.Single(tags);
|
||||
Assert.Equal("manual", tags[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_OnRunning_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_NotFound_Throws()
|
||||
{
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "UpdateTask"
|
||||
```
|
||||
Expected: errors that `UpdateTask` does not exist on `ExternalMcpService`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Add to `ExternalMcpService.cs` (after `AddTask`):
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("Update an existing task's title, description, commit type, and/or tags. Pass null to leave a field unchanged. Tags are replaced as a full set when non-null. Refuses if the task is currently Running.")]
|
||||
public async Task<TaskDto> UpdateTask(
|
||||
string taskId,
|
||||
string? title,
|
||||
string? description,
|
||||
string? commitType,
|
||||
IReadOnlyList<string>? tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status == TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot update a running task. Cancel it first.");
|
||||
|
||||
if (title is not null) task.Title = title;
|
||||
if (description is not null) task.Description = description;
|
||||
if (commitType is not null) task.CommitType = commitType;
|
||||
await _tasks.UpdateAsync(task, cancellationToken);
|
||||
|
||||
if (tags is not null)
|
||||
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
|
||||
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return ToDto(reload);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify production build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
```
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||
git commit -m "feat(mcp/external): add UpdateTask for content/tag patching"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: `DeleteTask`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `ExternalMcpServiceTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task DeleteTask_RemovesTaskAndTagJoins()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await sut.DeleteTask(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Null(await _tasks.GetByIdAsync(task.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTask_OnRunning_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.DeleteTask(task.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTask_NotFound_Throws()
|
||||
{
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.DeleteTask("does-not-exist", CancellationToken.None));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "DeleteTask"
|
||||
```
|
||||
Expected: errors that `DeleteTask` does not exist on `ExternalMcpService`.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Add to `ExternalMcpService.cs` (after `UpdateTask`):
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("Delete a task. Refuses if the task is currently Running — cancel it first.")]
|
||||
public async Task DeleteTask(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status == TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");
|
||||
|
||||
await _tasks.DeleteAsync(taskId, cancellationToken);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify production build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
```
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||
git commit -m "feat(mcp/external): add DeleteTask"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: `SetTaskTags`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `ExternalMcpServiceTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task SetTaskTags_ReplacesTagSetAndBroadcasts()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None);
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
||||
Assert.Single(tags);
|
||||
Assert.Equal("manual", tags[0].Name);
|
||||
Assert.Contains(_hub.RecordingClients.Proxy.Calls,
|
||||
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == task.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTaskTags_OnRunning_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
||||
using var queue = /* same construction */;
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "SetTaskTags"
|
||||
```
|
||||
Expected: errors that `SetTaskTags` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Add to `ExternalMcpService.cs` (after `DeleteTask`):
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("Replace the full tag set on an existing task. Missing tag names auto-create. Refuses if the task is Running.")]
|
||||
public async Task<TaskDto> SetTaskTags(
|
||||
string taskId,
|
||||
IReadOnlyList<string> tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status == TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot retag a running task. Cancel it first.");
|
||||
|
||||
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return ToDto(reload);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify production build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
```
|
||||
Expected: `Build succeeded. 0 Error(s)`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
|
||||
git commit -m "feat(mcp/external): add SetTaskTags"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final verification + docs touch
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/CLAUDE.md` (one-line update reflecting the new tools)
|
||||
|
||||
- [ ] **Step 1: Full production build**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
||||
```
|
||||
Expected: both succeed with 0 errors.
|
||||
|
||||
- [ ] **Step 2: Update Worker CLAUDE.md**
|
||||
|
||||
In `src/ClaudeDo.Worker/CLAUDE.md`, locate the existing line near the bottom of the file describing external MCP tools (search for `ExternalMcpService` or `External/`). If a list of tools is already there, append the new tool names: `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags`. If no such line exists, add one short line under an existing structural section, for example under "Architecture":
|
||||
|
||||
```markdown
|
||||
- **External/ExternalMcpService** — always-on MCP tools for general Claude sessions: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask` (with tags), `UpdateTask`, `UpdateTaskStatus`, `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`. Auth via optional `X-ClaudeDo-Key` header.
|
||||
```
|
||||
|
||||
If the file already has a similar line — replace it; do not duplicate.
|
||||
|
||||
- [ ] **Step 3: Verify the full test assembly state is unchanged**
|
||||
|
||||
```bash
|
||||
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep -E "error CS" | grep -v "PlanningChainCoordinator\|TaskRunner.*chain\|WorkerHub.*planningChain"
|
||||
```
|
||||
Expected: empty output (every remaining error must be one of the pre-existing `PlanningChainCoordinator`-related errors and nothing new).
|
||||
|
||||
- [ ] **Step 4: When the unrelated refactor lands, run the new tests**
|
||||
|
||||
(Defer to whoever lands the `PlanningChainCoordinator` refactor — they should run:)
|
||||
|
||||
```bash
|
||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~ExternalMcpServiceTests|FullyQualifiedName~TaskRepositoryTests.SetTagsAsync"
|
||||
```
|
||||
Expected: all new tests green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/CLAUDE.md
|
||||
git commit -m "docs(worker): document new external MCP tools"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
- `AddTask` extension with tags → Task 4 ✓
|
||||
- `UpdateTask` → Task 5 ✓
|
||||
- `DeleteTask` → Task 6 ✓
|
||||
- `SetTaskTags` → Task 7 ✓
|
||||
- `ListTags` → Task 3 ✓
|
||||
- `TaskRepository.SetTagsAsync` → Task 1 ✓
|
||||
- Auth (no change) → out of scope, called out in pre-flight ✓
|
||||
- Tests for each tool → Tasks 1, 3-7 ✓
|
||||
- Docs touch → Task 8 ✓
|
||||
|
||||
**Placeholder scan:** The phrase `/* same construction */` in tasks 4–7 is intentional — the engineer fills it in by mirroring the `QueueService` construction in Task 3 (which itself mirrors `QueueServiceTests.cs`). All other placeholders eliminated. No "TBD".
|
||||
|
||||
**Type consistency:**
|
||||
- `IReadOnlyList<string>` for tag inputs everywhere ✓
|
||||
- `TaskDto` returned by `AddTask`, `UpdateTask`, `SetTaskTags` ✓
|
||||
- `TagDto(long Id, string Name)` consistent across `ListTags` ✓
|
||||
- Constructor signature `(TaskRepository, ListRepository, QueueService, HubBroadcaster, TagRepository)` consistent between Task 3 implementation and Task 2 scaffold's `BuildSut` call ✓
|
||||
- Method `TaskRepository.SetTagsAsync(string, IReadOnlyList<string>, CancellationToken)` consistent with all callers ✓
|
||||
|
||||
No issues found.
|
||||
@@ -0,0 +1,225 @@
|
||||
# Session Prompts — Worker State & Queue Consolidation Slices 2–6
|
||||
|
||||
Paste-ready prompts for each remaining slice. Run **one slice per session** so the diff stays reviewable and tests stay green between commits. Spec lives at `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` — reference it when the prompt asks.
|
||||
|
||||
**Common ground rules** (carry across all slices):
|
||||
|
||||
- Direct on `main`, one commit per slice, conventional commit messages.
|
||||
- Build green (`dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` + Data + Ui) before commit.
|
||||
- Pre-existing test errors (TaskRunner/WorkerHub constructor drift in 4 test files) are **not** in scope to fix — they exist on `main` already. New compile errors my changes introduce ARE in scope.
|
||||
- No drive-by refactors outside the slice's stated scope.
|
||||
- New files must follow existing naming/folder conventions; legacy enum values stay until Slice 6.
|
||||
- After each slice, update `~/.claude/projects/C--Private-ClaudeDo/memory/` if I learn something durable about the codebase.
|
||||
|
||||
---
|
||||
|
||||
## Slice 2 — `TaskStateService` (centralized state machine)
|
||||
|
||||
**Prompt to paste into a fresh session:**
|
||||
|
||||
> Slice 2 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (sections 2 and 8). Slice 1 already landed (commit 7b737e6) — `TaskStatus` has `Idle`/`Cancelled`, `PlanningPhase` enum exists, `BlockedByTaskId` field exists. Legacy enum values still around.
|
||||
>
|
||||
> **Goal:** introduce `Worker/State/ITaskStateService` + `TaskStateService` as the single component that mutates `Status`, `PlanningPhase`, `BlockedByTaskId`. Migrate every existing caller. Mark repo `Mark*Async` helpers `internal`.
|
||||
>
|
||||
> **Public surface (verbatim from spec):**
|
||||
> ```csharp
|
||||
> Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct);
|
||||
> Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct);
|
||||
> Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
|
||||
> Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
|
||||
> Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
|
||||
> Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
|
||||
> Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct);
|
||||
> Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct);
|
||||
> Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
|
||||
> Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
|
||||
> Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct);
|
||||
> ```
|
||||
>
|
||||
> **Allowed transition table:** see spec §2. Reject invalid transitions with `TransitionResult(false, "<reason>")` — no exceptions. Each transition is one atomic `ExecuteUpdate` with `WHERE Status = <expected>` for TOCTOU-freedom.
|
||||
>
|
||||
> **Side effects after successful DB write** (do these inside the service so callers don't need to remember):
|
||||
> - On any `→ Queued`: call `_queue.WakeQueue()` directly for now (Slice 3 will replace with `IQueueWaker`). Inject `QueueService` lazily via `Func<QueueService>` to break the DI cycle if needed.
|
||||
> - On any successful transition: `_broadcaster.TaskUpdated(taskId)`.
|
||||
> - On `Done`/`Failed`/`Cancelled` for a child task: invoke `_chain.OnChildFinishedAsync(taskId, finalStatus, ct)`. If it returns a next-task-id, call `UnblockAsync` on it. Then run `_repo.TryCompleteParentAsync(parentId, ct)`.
|
||||
>
|
||||
> **Important:** `BlockOnAsync` and `UnblockAsync` should write `BlockedByTaskId` directly. `EnqueueAsync` for a Planning child should keep `BlockedByTaskId` null when it's the head of the chain. The chain coordinator will compose these calls in Slice 4 — for now just expose the API.
|
||||
>
|
||||
> **Caller migration (mechanical — preserve current behavior):**
|
||||
> - `TaskRunner.HandleSuccess` → replace `taskRepo.MarkDoneAsync` + `TryCompleteParentAsync` + `_chain.OnChildFinishedAsync` block with a single `_state.CompleteAsync(taskId, finishedAt, result, CancellationToken.None)`.
|
||||
> - `TaskRunner.HandleFailure` → `_state.FailAsync(taskId, finishedAt, errorMarkdown, CancellationToken.None)`.
|
||||
> - `TaskRunner.MarkFailed` (early-fail path) → same.
|
||||
> - `TaskRunner.RunAsync` start of run → `_state.StartRunningAsync(taskId, startedAt, ct)`.
|
||||
> - `StaleTaskRecovery.StartAsync` → `_state.RecoverStaleRunningAsync("worker restart", ct)`.
|
||||
> - `TaskResetService.ResetAsync` → `_state.ResetToIdleAsync(taskId, ct)` for the status flip; service keeps owning worktree cleanup.
|
||||
> - `PlanningSessionManager.StartAsync` (the `SetPlanningStartedAsync` call) → `_state.StartPlanningAsync(parentId, ct)`. The manager still owns token/session-dir setup; only the status flip moves.
|
||||
> - `PlanningChainCoordinator.OnChildFinishedAsync` (the `next.Status = TaskStatus.Queued` write) → keep its existing logic but use `_state.UnblockAsync(next.Id, ct)` for the actual write. The Slice 4 rewrite finishes the rest.
|
||||
> - `ExternalMcpService.UpdateTaskStatus` (status flip in the Queued case) → `_state.EnqueueAsync(taskId, ct)`. The Manual case stays as-is until Slice 6 since `Manual` is still a valid legacy value.
|
||||
>
|
||||
> **Repo helpers to mark `internal`:** `MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`, `FlipAllRunningToFailedAsync`. Verify nothing outside `ClaudeDo.Worker.State` calls them after migration. (`Worker.Tests` may need `InternalsVisibleTo` — add it if so.)
|
||||
>
|
||||
> **DI wiring:** register `TaskStateService` as Singleton in `Program.cs` for both the main app and the external-MCP app. The service holds no per-request state.
|
||||
>
|
||||
> **Tests:** new file `tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs`. At minimum:
|
||||
> - Happy path for each transition (verify DB state + side-effect mocks invoked).
|
||||
> - Reject path for each invalid transition (verify result + DB unchanged).
|
||||
> - Concurrency: two parallel `StartRunningAsync` for the same `Queued` task → exactly one returns `Ok=true`.
|
||||
> - Mock or fake the broadcaster, queue, and chain-coordinator dependencies. Use real SQLite for the DB (existing test pattern).
|
||||
>
|
||||
> Build all projects, run the worker test project (the 4 pre-existing constructor-drift errors are out of scope — but my changes shouldn't add new errors), commit as `refactor(worker/state): introduce TaskStateService and route mutations through it`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 3 — `IQueueWaker` + `IQueuePicker`
|
||||
|
||||
**Prompt to paste into a fresh session:**
|
||||
|
||||
> Slice 3 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 3). Slices 1 and 2 already landed.
|
||||
>
|
||||
> **Goal:** extract queue-wake and queue-pick from `QueueService` and `TaskRepository` into dedicated single-responsibility components. Make wakes automatic.
|
||||
>
|
||||
> **New components in `Worker/Queue/`:**
|
||||
> - `IQueueWaker` (interface, `void Wake()`). Backed by `QueueWaker` singleton holding the existing `SemaphoreSlim`. Inject into `TaskStateService` (replaces the direct `QueueService` ref from Slice 2) and into `QueueService` itself.
|
||||
> - `IQueuePicker` with `Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct)`. Implementation `QueuePicker` moves the raw SQL out of `TaskRepository.GetNextQueuedAgentTaskAsync` and **adds a `blocked_by_task_id IS NULL` filter to the WHERE clause**. Order stays `sort_order ASC, created_at ASC` (verify the existing query — add ORDER BY if missing). Atomic `UPDATE … RETURNING` flips `Queued → Running` and writes `started_at`.
|
||||
>
|
||||
> **Caller updates:**
|
||||
> - `TaskStateService` swaps its `Func<QueueService>` for `IQueueWaker`. The `→ Queued` side-effect now calls `_waker.Wake()`.
|
||||
> - `QueueService.ExecuteAsync` calls `_picker.ClaimNextAsync` instead of `_taskRepo.GetNextQueuedAgentTaskAsync`. The slot-claim, broadcaster, and `WakeQueue()` after slot release stay where they are.
|
||||
> - `WorkerHub.WakeQueue()` and `ExternalMcpService.WakeQueue` calls in app code → remove the explicit invocations. The state-service triggers waking automatically. **Keep** the SignalR/MCP endpoint that exposes `WakeQueue()` for diagnostics/manual use — that one delegates to `_waker.Wake()`.
|
||||
> - `TaskRepository.GetNextQueuedAgentTaskAsync` becomes a thin shim that forwards to `IQueuePicker` for any remaining tests, OR delete it and update tests to use the picker. Prefer delete if tests are easy to migrate.
|
||||
>
|
||||
> **Tests:** new `tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs`:
|
||||
> - Skipped: `BlockedByTaskId` set; missing agent tag; `scheduled_for > now`; status not Queued.
|
||||
> - Picked: correct order (`sort_order, created_at`).
|
||||
> - Atomic claim: two parallel pickers → exactly one row returned non-null, the other null.
|
||||
>
|
||||
> Update existing `TaskRepositoryTests.GetNextQueuedAgentTaskAsync_*` tests if they exercised the removed method.
|
||||
>
|
||||
> Build, test, commit as `refactor(worker/queue): split queue waker and picker, auto-wake on enqueue`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 4 — Planning flow consolidation (kills the original bug)
|
||||
|
||||
**Prompt to paste into a fresh session:**
|
||||
|
||||
> Slice 4 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 4). Slices 1–3 already landed. **This slice eliminates the original "queue never picks up planning tasks" bug structurally.**
|
||||
>
|
||||
> **Goal:** one path through planning. Delete the dual-flow problem.
|
||||
>
|
||||
> **Changes:**
|
||||
> - **Delete** `TaskRepository.FinalizePlanningAsync` entirely. Also delete its tests in `TaskRepositoryPlanningTests.cs`.
|
||||
> - **Rewrite** `PlanningSessionManager.FinalizeAsync(taskId, queueAgentTasks, ct)`:
|
||||
> 1. `_state.FinalizePlanningAsync(parentId, ct)` (sets parent `PlanningPhase=Finalized`, `Status=Idle`).
|
||||
> 2. If `queueAgentTasks` is true, call the new `_chainCoordinator.SetupChainAsync(parentId, ct)`.
|
||||
> 3. Existing worktree-cleanup + session-dir-deletion remains.
|
||||
> 4. Return the count of children that ended up in the chain.
|
||||
> - **Rename** `PlanningChainCoordinator.QueueSubtasksSequentiallyAsync` → `SetupChainAsync`. Make it `internal`. New behavior:
|
||||
> - Eligibility check: children must be in `Status=Idle` (was `Manual` or `Planned` legacy values — keep tolerating those for one slice via OR).
|
||||
> - Auto-attach `agent` tag to all children (already in WIP — keep that behavior).
|
||||
> - For first child: `_state.EnqueueAsync(child[0].Id, ct)` (no BlockedBy, head of chain).
|
||||
> - For rest: `_state.EnqueueAsync(child[i].Id, ct)` followed immediately by `_state.BlockOnAsync(child[i].Id, child[i-1].Id, ct)`. (Or: add a single `EnqueueBlockedAsync` helper to TaskStateService if call-site clutter bothers you.)
|
||||
> - **Update** `PlanningChainCoordinator.OnChildFinishedAsync`: replace status-via-LINQ logic with: query for the next child where `BlockedByTaskId == childTaskId`, call `_state.UnblockAsync` on it. Drop the `Waiting` lookup entirely.
|
||||
> - Audit `Status == TaskStatus.Waiting` in UI/tests — replace with `Status == Queued && BlockedByTaskId != null`. (UI changes confirmed against `TaskRowViewModel`, `TasksIslandViewModel` from Slice 1's WIP.)
|
||||
>
|
||||
> **Regression test:** new `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs` (or extend existing) — `Active` parent + 3 drafts → call `FinalizeAsync(queueAgentTasks: true)` → assert within 200 ms the first child has `Status=Running` (queue picker claimed it) without anyone calling `WakeQueue()` manually. This was the bug the user originally reported.
|
||||
>
|
||||
> **Update** `PlanningMcpService.EditableStatuses` — replace `Waiting` with `Queued` (since blocked tasks are now `Queued + BlockedByTaskId`). Verify the MCP tool still gates on `parent.PlanningPhase == Active` (legacy: `parent.Status == Planning`).
|
||||
>
|
||||
> Build, test, commit as `feat(planning): consolidate finalize+chain via TaskStateService, fix queue pickup`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 5 — `OverrideSlotService` + folder reorg
|
||||
|
||||
**Prompt to paste into a fresh session:**
|
||||
|
||||
> Slice 5 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 5). Slices 1–4 already landed.
|
||||
>
|
||||
> **Goal:** split the override slot out of QueueService and reorganize `Worker/Services/` into domain folders.
|
||||
>
|
||||
> **`OverrideSlotService` (new in `Worker/Queue/`):**
|
||||
> - Owns the `_overrideSlot` field, `RunNow(taskId)`, `ContinueTask(taskId, followUpPrompt)`, and the override-slot piece of `CancelTask`.
|
||||
> - Status mutations go through `TaskStateService.StartRunningAsync` (non-atomic claim is fine; serialized by slot lock).
|
||||
> - `QueueService.CancelTask` delegates to `OverrideSlotService.TryCancel` first, falls back to its own queue slot.
|
||||
> - WorkerHub's `RunNow`/`ContinueTask`/`CancelTask` SignalR endpoints route to the new service via `OverrideSlotService` when applicable; keep the signatures stable.
|
||||
>
|
||||
> **Folder reorg** (use `git mv`, don't copy/delete):
|
||||
> ```
|
||||
> Worker/State/ ← ITaskStateService.cs, TaskStateService.cs, TransitionResult.cs (already exist; no move needed if already there)
|
||||
> Worker/Queue/ ← IQueueWaker.cs, QueueWaker.cs, IQueuePicker.cs, QueuePicker.cs, QueueService.cs, OverrideSlotService.cs, QueueSlotState.cs
|
||||
> Worker/Lifecycle/ ← StaleTaskRecovery.cs, TaskResetService.cs, TaskMergeService.cs
|
||||
> Worker/Worktrees/ ← WorktreeMaintenanceService.cs
|
||||
> Worker/Agents/ ← AgentFileService.cs, DefaultAgentSeeder.cs
|
||||
> Worker/Runner/ ← unchanged
|
||||
> Worker/Planning/ ← unchanged
|
||||
> Worker/External/ ← unchanged
|
||||
> Worker/Hub/ ← unchanged
|
||||
> ```
|
||||
>
|
||||
> Update namespaces to match folders (existing convention: namespace == folder path under `ClaudeDo.Worker`). Delete the old `Worker/Services/` folder once empty.
|
||||
>
|
||||
> Update DI registrations in `Program.cs` (both apps) — most calls just need `using` updates. `OverrideSlotService` is a new singleton.
|
||||
>
|
||||
> Update test `using` statements to follow.
|
||||
>
|
||||
> Build, test, commit as `refactor(worker): extract OverrideSlotService and reorganize Worker/Services into domain folders`.
|
||||
|
||||
---
|
||||
|
||||
## Slice 6 — Cleanup, legacy retirement, docs
|
||||
|
||||
**Prompt to paste into a fresh session:**
|
||||
|
||||
> Slice 6 (final) of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 6 + slice plan). Slices 1–5 already landed.
|
||||
>
|
||||
> **Goal:** retire legacy enum values, backfill DB rows, update docs.
|
||||
>
|
||||
> **EF migration `RetireLegacyTaskStatus`:**
|
||||
> ```sql
|
||||
> UPDATE tasks SET status='idle' WHERE status IN ('manual', 'draft');
|
||||
> UPDATE tasks SET status='idle', planning_phase='active' WHERE status='planning';
|
||||
> UPDATE tasks SET status='idle', planning_phase='finalized' WHERE status='planned';
|
||||
>
|
||||
> -- Waiting → Queued + blocked_by from sort_order:
|
||||
> WITH ordered AS (
|
||||
> SELECT id,
|
||||
> LAG(id) OVER (PARTITION BY parent_task_id ORDER BY sort_order, created_at) AS prev_id
|
||||
> FROM tasks WHERE status='waiting'
|
||||
> )
|
||||
> UPDATE tasks
|
||||
> SET status='queued',
|
||||
> blocked_by_task_id=(SELECT prev_id FROM ordered WHERE ordered.id=tasks.id)
|
||||
> WHERE id IN (SELECT id FROM ordered);
|
||||
> ```
|
||||
> Use `migrationBuilder.Sql(...)` for these. Down() is best-effort: `Cancelled` → `Failed`, `(idle, finalized)` → `planned`, `(idle, active)` → `planning`, `queued + blocked_by_task_id != null` → `waiting`. Document lossiness in a comment.
|
||||
>
|
||||
> **Code changes:**
|
||||
> - Remove legacy values from `TaskStatus` enum: `Manual, Planning, Planned, Draft, Waiting`.
|
||||
> - Strip the legacy branches from `TaskEntityConfiguration.StatusToString`/`StatusFromString`.
|
||||
> - Default for `TaskEntity.Status` is `TaskStatus.Idle` (already correct after Slice 1's revert).
|
||||
> - Audit + remap every remaining caller — they should already use new values from Slices 2–4, but search for any leftover `TaskStatus.Manual` etc. in:
|
||||
> - tests (~10 files seed status — flip to `Idle`/`Queued`/etc.)
|
||||
> - UI (`TaskRowViewModel.IsPlanningParent`, `IsDraft`, `CanOpenPlanningSession`, status maps — replace with `PlanningPhase` checks where appropriate)
|
||||
> - any leftover guards in MCP/services
|
||||
> - Mark `Mark*Async` repo helpers as `internal` if not already (Slice 2 should have done this — verify).
|
||||
>
|
||||
> **Docs to update:**
|
||||
> - `src/ClaudeDo.Worker/CLAUDE.md` — new folder structure, new state-service flow, new wake mechanics, removal of legacy values.
|
||||
> - `src/ClaudeDo.Data/CLAUDE.md` — TaskEntity new fields (`PlanningPhase`, `BlockedByTaskId`), retired legacy enum values, new tag-attach behavior.
|
||||
> - `docs/plan.md` — update status flow section.
|
||||
> - `docs/open.md` — close the "queue doesn't pick up planning tasks" item if it's tracked there; add any follow-ups discovered along the way.
|
||||
> - Memory: update `~/.claude/projects/C--Private-ClaudeDo/memory/` with a new entry summarizing the new architecture (state-service + queue split + planning chain via blocked-by).
|
||||
>
|
||||
> **Sanity tests** — full test run. The 4 pre-existing constructor-drift errors should still be the only failures. If new ones surfaced from missed legacy-value remappings, fix them before commit.
|
||||
>
|
||||
> Build, full test run, commit as `refactor(data): retire legacy TaskStatus values and backfill existing rows`.
|
||||
|
||||
---
|
||||
|
||||
## After Slice 6
|
||||
|
||||
- All 6 slices on `main`.
|
||||
- The original bug ("queue doesn't pick up planning tasks") is structurally impossible.
|
||||
- Worker has clear domain folders, single state-mutator, single queue-picker.
|
||||
- Spec doc + this prompt file can be deleted or moved to `docs/superpowers/done/`.
|
||||
2424
docs/superpowers/plans/2026-04-28-tabbed-settings-prime-claude.md
Normal file
2424
docs/superpowers/plans/2026-04-28-tabbed-settings-prime-claude.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
# External MCP — CRUD Extensions
|
||||
|
||||
**Date:** 2026-04-25
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Give a normal (non-planning) Claude CLI session full control over the ClaudeDo task inbox via the existing always-on `ExternalMcpService`. Primary use case: when a chat session produces scope-creep work, Claude can spin up a fully-formed task — title, description, tags (including the `agent` tag for auto-execution) — without leaving the session.
|
||||
|
||||
The work is purely additive: the `ExternalMcpService` endpoint is already wired, authenticated by the optional `X-ClaudeDo-Key` header, and exposes `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTaskStatus`, `RunTaskNow`, `CancelTask`. Missing for "full CRUD" are tag handling, content updates, deletion, and tag discovery.
|
||||
|
||||
## Scope
|
||||
|
||||
| Tool | Status | Notes |
|
||||
|---|---|---|
|
||||
| `ListTaskLists` | exists | unchanged |
|
||||
| `ListTasks` | exists | unchanged |
|
||||
| `GetTask` | exists | unchanged |
|
||||
| `AddTask` | extend | add optional `tags` parameter |
|
||||
| `UpdateTaskStatus` | exists | unchanged (Manual ↔ Queued) |
|
||||
| `RunTaskNow` | exists | unchanged |
|
||||
| `CancelTask` | exists | unchanged |
|
||||
| `UpdateTask` | new | patch title/description/commitType/tags |
|
||||
| `DeleteTask` | new | delete a task (cascades) |
|
||||
| `SetTaskTags` | new | replace the full tag set on a task |
|
||||
| `ListTags` | new | enumerate all known tag names |
|
||||
|
||||
Out of scope:
|
||||
- List CRUD (creating/renaming/deleting lists) — out of scope for this iteration; UI remains the source of truth for list management.
|
||||
- ListConfig / agent settings overrides — handled by the UI, not surfaced via MCP here.
|
||||
- Tag CRUD beyond auto-creation during `AddTask` / `UpdateTask` / `SetTaskTags`. There is no `DeleteTag` tool; tag rows live as long as some task references them.
|
||||
|
||||
## Authentication
|
||||
|
||||
No change. The endpoint continues to be gated by `ExternalMcpAuthMiddleware` — if `WorkerConfig.ExternalMcpApiKey` is set, callers must include `X-ClaudeDo-Key`; otherwise the loopback-only worker is open to local processes.
|
||||
|
||||
## Tool specifications
|
||||
|
||||
### `AddTask` (extended)
|
||||
|
||||
```
|
||||
AddTask(
|
||||
listId: string,
|
||||
title: string,
|
||||
description: string?,
|
||||
createdBy: string,
|
||||
queueImmediately: bool,
|
||||
tags: string[]?,
|
||||
cancellationToken)
|
||||
-> TaskDto
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Existing behavior preserved. New `tags` parameter, when non-null, attaches the named tags to the new task.
|
||||
- Tag names are matched case-insensitively against existing rows; missing tag rows are auto-created (mirrors `TaskRepository.CreateChildAsync`).
|
||||
- Empty/whitespace tag names are skipped; duplicates are deduplicated.
|
||||
- `tags` is the LAST parameter before `CancellationToken` so existing positional callers are unaffected (CancellationToken is bound by name in MCP runtime; defensive — see Migration).
|
||||
|
||||
### `UpdateTask` (new)
|
||||
|
||||
```
|
||||
UpdateTask(
|
||||
taskId: string,
|
||||
title: string?,
|
||||
description: string?,
|
||||
commitType: string?,
|
||||
tags: string[]?,
|
||||
cancellationToken)
|
||||
-> TaskDto
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Loads the task; throws `InvalidOperationException` if not found.
|
||||
- **Refuses if status is `Running`** — protects in-flight worktrees and the streaming log.
|
||||
- Does NOT change status (use `UpdateTaskStatus`) and does NOT change `createdBy`, `listId`, or `parentTaskId` (audit + structural fields, immutable here).
|
||||
- For each non-null parameter, applies the update. Null means "leave unchanged".
|
||||
- `tags` semantics: full replacement of the tag set (same as `SetTaskTags`). Auto-creates missing tag rows.
|
||||
- Broadcasts `TaskUpdated` on the SignalR hub on success.
|
||||
|
||||
### `DeleteTask` (new)
|
||||
|
||||
```
|
||||
DeleteTask(taskId: string, cancellationToken) -> void
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Loads the task; throws if not found.
|
||||
- **Refuses if status is `Running`** — caller must `CancelTask` first.
|
||||
- Calls `TaskRepository.DeleteAsync` (FK cascades remove `task_tags`, `worktrees`, `task_runs`, `subtasks`).
|
||||
- Broadcasts `TaskUpdated(taskId)` so UIs drop the row.
|
||||
|
||||
### `SetTaskTags` (new)
|
||||
|
||||
```
|
||||
SetTaskTags(taskId: string, tags: string[], cancellationToken) -> TaskDto
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Convenience wrapper for "I just want to (re)set tags". Equivalent to `UpdateTask(taskId, null, null, null, tags)`.
|
||||
- Same validation: refuses if `Running`.
|
||||
- Returns the updated `TaskDto` (with status; tags are not included in `TaskDto` today — see Open Decisions).
|
||||
|
||||
### `ListTags` (new)
|
||||
|
||||
```
|
||||
ListTags(cancellationToken) -> { Id: long, Name: string }[]
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Returns every row in the `tags` table. No filter, no pagination — the table is small (seed values + user-defined).
|
||||
- Lets Claude discover existing tag names (`agent`, `manual`, plus any user-defined) before tagging, avoiding duplicates that differ only by case/whitespace.
|
||||
|
||||
## Repository changes
|
||||
|
||||
`src/ClaudeDo.Data/Repositories/TaskRepository.cs`:
|
||||
|
||||
- Add `public Task SetTagsAsync(string taskId, IReadOnlyList<string> tagNames, CancellationToken ct = default)` — replaces the tag set, auto-creates missing rows. Implementation pattern matches the tag block already inside `CreateChildAsync` and the new `UpdateChildAsync` from the planning-MCP work; consider extracting a private helper `ApplyTagsAsync(TaskEntity, IReadOnlyList<string>, CancellationToken)` shared by both.
|
||||
|
||||
`src/ClaudeDo.Data/Repositories/TagRepository.cs`:
|
||||
|
||||
- Add `public Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)` if it does not already exist. (Matches `ListRepository.GetAllAsync` style.)
|
||||
|
||||
No new tables, no migrations.
|
||||
|
||||
## Service changes
|
||||
|
||||
`src/ClaudeDo.Worker/External/ExternalMcpService.cs`:
|
||||
|
||||
- Add `TagRepository` to the constructor (DI registration is already in place since the planning service uses it).
|
||||
- Extend `AddTask` signature with `IReadOnlyList<string>? tags` and apply via the repository.
|
||||
- Add `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags` methods, each annotated `[McpServerTool, Description("…")]`.
|
||||
- Each new mutating tool calls `_broadcaster.TaskUpdated(taskId)` on success (matches existing pattern in this file).
|
||||
|
||||
DI: `ExternalMcpService` is already registered. If `TagRepository` is not already registered (it is — used by `ListRepository`), no change. If a constructor parameter is added, `Program.cs` does not need changes because services are scoped/transient.
|
||||
|
||||
## Error handling
|
||||
|
||||
All errors raised as `InvalidOperationException` with a human-readable message — matches the existing pattern in `ExternalMcpService` and `PlanningMcpService`. The MCP SDK serializes these to the JSON-RPC error channel; Claude sees the message text directly.
|
||||
|
||||
Specific cases:
|
||||
- Task not found → `"Task {id} not found."`
|
||||
- Running-task guard → `"Cannot {update|delete} a running task. Cancel it first."`
|
||||
- Unknown status (in `UpdateTaskStatus`, unchanged) → `"Unknown status '{x}'."`
|
||||
|
||||
## Testing
|
||||
|
||||
Add `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` (or extend if it exists) with:
|
||||
|
||||
| Test | Asserts |
|
||||
|---|---|
|
||||
| `AddTask_WithTags_AttachesTags` | `tags` param creates and attaches tag rows |
|
||||
| `AddTask_WithUnknownTag_AutoCreatesTagRow` | new tag name produces a row in `tags` table |
|
||||
| `UpdateTask_PatchesNonNullFields` | only non-null fields change |
|
||||
| `UpdateTask_OnRunning_Throws` | `InvalidOperationException` |
|
||||
| `UpdateTask_BroadcastsTaskUpdated` | hub broadcast received |
|
||||
| `UpdateTask_TagsReplaceFullSet` | passing tags=[…] replaces existing tags wholesale |
|
||||
| `DeleteTask_RemovesTaskAndTagJoins` | task and `task_tags` rows gone |
|
||||
| `DeleteTask_OnRunning_Throws` | `InvalidOperationException` |
|
||||
| `SetTaskTags_ReplacesAndBroadcasts` | replacement semantics + broadcast |
|
||||
| `ListTags_ReturnsSeedAndCustomTags` | `agent` + `manual` + any user-defined |
|
||||
|
||||
Existing test infrastructure (`DbFixture`, `FakeHubContext`) is reused. No new fakes required.
|
||||
|
||||
**Caveat:** the test assembly currently fails to compile on `main` because of pre-existing in-progress work on `PlanningChainCoordinator` (missing constructor argument in `WorkerHub`/`TaskRunner` test instantiations). Tests will pass only after that work lands; do not block this design on it.
|
||||
|
||||
## Open decisions (defaults chosen, easy to flip)
|
||||
|
||||
1. **`TaskDto` does not currently include tags.** For consistency, the spec keeps `TaskDto` as-is and ships a separate `ListTags` tool. If preferred, we could add `Tags: string[]` to `TaskDto` so every tool response includes them — small DB cost (one extra `SelectMany`), one struct field added. Default: leave `TaskDto` alone, defer.
|
||||
2. **Per-tag `AddTaskTag` / `RemoveTaskTag` micro-tools.** Skipped — `SetTaskTags` covers the use case, and it's idempotent. Add later if granular ops are wanted.
|
||||
3. **List CRUD via MCP.** Out of scope. UI owns lists.
|
||||
|
||||
## Migration / compatibility
|
||||
|
||||
`AddTask` gains an optional parameter. The MCP server SDK sends parameters by name in JSON-RPC `params`, so existing clients that omit `tags` continue to work without code changes. No version bump required.
|
||||
@@ -0,0 +1,297 @@
|
||||
# Worker State & Queue Consolidation — Design
|
||||
|
||||
**Date:** 2026-04-27
|
||||
**Status:** Approved (brainstorming)
|
||||
**Scope:** `ClaudeDo.Worker` + `ClaudeDo.Data` (TaskEntity, TaskRepository), EF migration
|
||||
|
||||
## Problem
|
||||
|
||||
The worker layer has accumulated structural problems that culminate in a concrete bug — the queue does not pick up tasks created by a planning session.
|
||||
|
||||
### Concrete bug
|
||||
|
||||
`TaskRepository.FinalizePlanningAsync(parentId, queueAgentTasks=true)` only flips a draft child to `Queued` if the child *or* its list carries the `agent` tag:
|
||||
|
||||
```csharp
|
||||
var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag);
|
||||
```
|
||||
|
||||
When neither carries the tag, the child silently becomes `Manual` — the queue ignores it. There is no UI feedback. Users observe "queue never picks up planning tasks".
|
||||
|
||||
### Underlying design issues
|
||||
|
||||
1. **Status enum mixes orthogonal concerns.** Today's `TaskStatus` carries 10 values: lifecycle (`Manual, Queued, Running, Done, Failed`), planning hierarchy (`Planning, Planned`), chain ordering (`Waiting`), and an unclear `Draft`. Every consumer has to know which subset applies in which context.
|
||||
2. **Status writes are scattered.** TaskRunner, StaleTaskRecovery, PlanningChainCoordinator, FinalizePlanningAsync, TaskResetService, ExternalMcpService, and PlanningMcpService all mutate `Status` directly. Some go through `TaskRepository.Mark*Async` helpers, some do `task.Status = …` straight on the DbContext (PlanningChainCoordinator).
|
||||
3. **Guards are duplicated.** `if (Status == Running) throw …` appears in at least four places (delete, retag, merge, reset).
|
||||
4. **Two competing planning flows.** `FinalizePlanningAsync` (parallel queueing in Repo) and `PlanningChainCoordinator.QueueSubtasksSequentiallyAsync` (sequential chain) make incompatible assumptions about child status.
|
||||
5. **`WakeQueue()` is manual.** Multiple callers must remember to invoke it after any DB mutation that creates a `Queued` task. `QueueSubtasksSequentiallyAsync` forgets to. The queue only picks up after a backstop tick.
|
||||
6. **`Worker/Services/` is a grab-bag.** Queue, lifecycle, merge, worktree maintenance, agent files, and recovery sit side-by-side without domain boundaries.
|
||||
|
||||
## Goals
|
||||
|
||||
- One source of truth for status mutations: `TaskStateService`.
|
||||
- Status enum reflects only lifecycle. Planning state and chain blocking are separate fields.
|
||||
- Wake-queue side effects are automatic, not caller-driven.
|
||||
- Planning finalization has exactly one path.
|
||||
- `Worker/Services/` is split into domain folders.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No change to UI status-rendering logic beyond adapting to renamed values.
|
||||
- No change to SignalR/MCP wire formats beyond the necessary status-string updates.
|
||||
- No change to git/worktree behavior.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Status model reform
|
||||
|
||||
Replace today's single `TaskStatus` with three orthogonal fields on `TaskEntity`.
|
||||
|
||||
#### `TaskStatus` (lifecycle only) — 6 values
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `Idle` | not in queue, not active. Replaces today's `Manual` and `Draft`. |
|
||||
| `Queued` | waiting for queue pickup. |
|
||||
| `Running` | currently executing. |
|
||||
| `Done` | finished successfully. |
|
||||
| `Failed` | finished with error. |
|
||||
| `Cancelled` | aborted by user (today conflated with `Failed`). |
|
||||
|
||||
#### `PlanningPhase` (parent-only, new column) — 3 values
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `None` | no planning session. Default for all tasks. |
|
||||
| `Active` | planning session is running. Replaces `Status=Planning`. |
|
||||
| `Finalized` | plan is committed, children exist. Replaces `Status=Planned`. |
|
||||
|
||||
A parent task can now be `Status=Idle, PlanningPhase=Finalized` simultaneously, enabling re-runs of finalized plans without losing planning metadata.
|
||||
|
||||
#### `BlockedByTaskId` (nullable FK, new column) — replaces `Waiting`
|
||||
|
||||
- Today: `Status=Waiting` means "waiting on a predecessor in the chain".
|
||||
- New: `Status=Queued` AND `BlockedByTaskId=<predecessor>`. Picker filters out any row with `BlockedByTaskId IS NOT NULL`.
|
||||
- `ON DELETE SET NULL` — if predecessor is deleted, child becomes pickable.
|
||||
|
||||
### 2. `TaskStateService` (centralized state machine)
|
||||
|
||||
The only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All other code goes through it.
|
||||
|
||||
```csharp
|
||||
public interface ITaskStateService
|
||||
{
|
||||
Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct);
|
||||
Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct);
|
||||
Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
|
||||
Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
|
||||
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
|
||||
Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
|
||||
|
||||
Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct);
|
||||
Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct);
|
||||
|
||||
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
|
||||
Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
|
||||
|
||||
Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record TransitionResult(bool Ok, string? Reason);
|
||||
```
|
||||
|
||||
#### Allowed transitions
|
||||
|
||||
```
|
||||
Idle → Queued | Running (RunNow)
|
||||
Queued → Running | Cancelled | Idle (ResetToIdle)
|
||||
Running → Done | Failed | Cancelled
|
||||
Done → Idle (ResetToIdle, for re-run)
|
||||
Failed → Idle | Queued (re-queue)
|
||||
Cancelled → Idle | Queued
|
||||
```
|
||||
|
||||
Anything else returns `TransitionResult(false, "invalid transition X→Y")`. No exceptions for invalid transitions — Result pattern keeps callers tolerant.
|
||||
|
||||
#### Invariants
|
||||
|
||||
1. **Atomic.** Each transition is a single `ExecuteUpdate` (or short tx) using `WHERE Status = <expected>` to be TOCTOU-free.
|
||||
2. **Validated.** Source status is verified at the SQL level, not in C#.
|
||||
3. **Side effects (after successful DB write):**
|
||||
- On any `→ Queued`: `IQueueWaker.Wake()`.
|
||||
- On any successful transition: `HubBroadcaster.TaskUpdated(taskId)`.
|
||||
- On `Done`/`Failed`/`Cancelled` for a child task: `IPlanningChainCoordinator.OnChildFinishedAsync`, which calls `_state.UnblockAsync(nextChild)` and `TryCompleteParent` if applicable.
|
||||
4. **No caller responsibility for side effects.** A caller only needs to invoke one method.
|
||||
|
||||
#### Caller migration
|
||||
|
||||
| Today | New |
|
||||
|---|---|
|
||||
| `TaskRunner.MarkRunningAsync` | `_state.StartRunningAsync` |
|
||||
| `TaskRunner.HandleSuccess` (Mark + chain + parent) | `_state.CompleteAsync` (handles all) |
|
||||
| `TaskRunner.HandleFailure` | `_state.FailAsync` |
|
||||
| `StaleTaskRecovery.FlipAllRunningToFailedAsync` | `_state.RecoverStaleRunningAsync("worker restart")` |
|
||||
| `PlanningChainCoordinator.QueueSubtasksSequentiallyAsync` (direct DbContext) | iterates children, calls `_state.EnqueueAsync` for first, `_state.BlockOnAsync` for rest |
|
||||
| `TaskRepository.FinalizePlanningAsync` | **removed**; `PlanningSessionManager` orchestrates via state-service |
|
||||
| `TaskResetService` (direct DbContext) | `_state.ResetToIdleAsync` (service only owns worktree-cleanup) |
|
||||
|
||||
`Mark*Async` repo helpers stay but become `internal` — used only by `TaskStateService`.
|
||||
|
||||
### 3. Queue dispatch & wake mechanics
|
||||
|
||||
Three classes, clear responsibilities.
|
||||
|
||||
#### `IQueueWaker`
|
||||
|
||||
```csharp
|
||||
public interface IQueueWaker { void Wake(); }
|
||||
```
|
||||
|
||||
- Singleton. Backed by today's `SemaphoreSlim`.
|
||||
- Called automatically by `TaskStateService` after any `→ Queued` transition.
|
||||
- Manual `WakeQueue()` calls in app code are removed (Hub `WakeQueue` SignalR endpoint stays for diagnostics but maps directly to `IQueueWaker.Wake`).
|
||||
|
||||
#### `IQueuePicker`
|
||||
|
||||
```csharp
|
||||
public interface IQueuePicker
|
||||
{
|
||||
Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
- The single place where queue selection happens.
|
||||
- Filter (all required):
|
||||
- `Status == Queued`
|
||||
- `BlockedByTaskId IS NULL`
|
||||
- `(ScheduledFor IS NULL OR ScheduledFor <= :now)`
|
||||
- `EXISTS task_tags WHERE name='agent'` OR `EXISTS list_tags WHERE name='agent'`
|
||||
- Order: `SortOrder ASC, CreatedAt ASC`.
|
||||
- Atomic claim via `UPDATE … RETURNING` (matching today's pattern), flips `Queued → Running` and writes `StartedAt`.
|
||||
- Picker is the sole caller of `Queued → Running` transition. `TaskStateService.StartRunningAsync` exists for the override slot path (RunNow / Continue).
|
||||
|
||||
#### `QueueService` (BackgroundService) — slimmer
|
||||
|
||||
- Wait on wake-signal or backstop timer.
|
||||
- Call `_picker.ClaimNextAsync`.
|
||||
- If task: occupy queue slot, run via `_runner.RunAsync`, in `ContinueWith` invoke `_waker.Wake()` for the next pickup.
|
||||
- No DbContext. No status mutation. No DTO knowledge.
|
||||
|
||||
#### `OverrideSlotService` (new)
|
||||
|
||||
- Owns `RunNow` and `ContinueTask` (today both in `QueueService`).
|
||||
- Holds the override slot state.
|
||||
- Status mutations go through `TaskStateService.StartRunningAsync` (non-atomic claim — caller-driven, fine because override is user-initiated and serialized by slot lock).
|
||||
|
||||
### 4. Planning chain integration
|
||||
|
||||
Single flow, replaces both `FinalizePlanningAsync` (Repo) and `QueueSubtasksSequentiallyAsync` (Coordinator).
|
||||
|
||||
1. `PlanningSessionManager.StartAsync(parentId)` → `_state.StartPlanningAsync` → parent `PlanningPhase=Active`.
|
||||
2. User edits children in MCP tool. Children are in `Status=Idle`.
|
||||
3. `PlanningSessionManager.FinalizeAsync(parentId)`:
|
||||
- `_state.FinalizePlanningAsync(parentId)` → parent `PlanningPhase=Finalized, Status=Idle`.
|
||||
- `_chainCoordinator.SetupChainAsync(parentId)`:
|
||||
- Attaches `agent` tag to all children (automatic — confirmed in brainstorming).
|
||||
- `_state.EnqueueAsync(children[0])` → wake fires.
|
||||
- `_state.BlockOnAsync(children[i], children[i-1])` for `i ≥ 1`.
|
||||
4. When a child finishes, `TaskRunner.HandleSuccess` calls `_state.CompleteAsync(child)`. State-service internally invokes `_chainCoordinator.OnChildFinishedAsync`, which calls `_state.UnblockAsync(nextChild)` (wake fires). Predecessor block goes away because of `ON DELETE SET NULL`-style logic in `UnblockAsync`.
|
||||
5. When all children are terminal: `_state` runs `TryCompleteParent` and sets parent `Done`/`Failed` based on aggregate.
|
||||
|
||||
`TaskRepository.FinalizePlanningAsync` is **deleted**. `QueueSubtasksSequentiallyAsync` is renamed to `SetupChainAsync` and made internal to the coordinator (called only from `PlanningSessionManager.FinalizeAsync`).
|
||||
|
||||
### 5. `Worker/Services/` reorganization
|
||||
|
||||
```
|
||||
Worker/
|
||||
State/
|
||||
ITaskStateService.cs
|
||||
TaskStateService.cs
|
||||
TransitionResult.cs
|
||||
Queue/
|
||||
IQueueWaker.cs
|
||||
IQueuePicker.cs
|
||||
QueuePicker.cs
|
||||
QueueService.cs (BackgroundService, slimmer)
|
||||
OverrideSlotService.cs
|
||||
QueueSlotState.cs
|
||||
Lifecycle/
|
||||
StaleTaskRecovery.cs
|
||||
TaskResetService.cs
|
||||
TaskMergeService.cs
|
||||
Worktrees/
|
||||
WorktreeMaintenanceService.cs
|
||||
Agents/
|
||||
AgentFileService.cs
|
||||
DefaultAgentSeeder.cs
|
||||
Runner/ (unchanged)
|
||||
Planning/ (ChainCoordinator simplified)
|
||||
External/ (unchanged)
|
||||
Hub/ (unchanged)
|
||||
```
|
||||
|
||||
`WorkerHub` calls fewer services — typically `_state.X` plus a domain service for non-status work (Merge, Worktree-Cleanup).
|
||||
|
||||
### 6. EF migration
|
||||
|
||||
```sql
|
||||
ALTER TABLE tasks ADD COLUMN planning_phase INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE tasks ADD COLUMN blocked_by_task_id TEXT NULL REFERENCES tasks(id) ON DELETE SET NULL;
|
||||
CREATE INDEX ix_tasks_blocked_by ON tasks(blocked_by_task_id);
|
||||
|
||||
UPDATE tasks SET status='idle' WHERE status='manual';
|
||||
UPDATE tasks SET status='idle' WHERE status='draft';
|
||||
UPDATE tasks SET status='idle', planning_phase=1 WHERE status='planning';
|
||||
UPDATE tasks SET status='idle', planning_phase=2 WHERE status='planned';
|
||||
```
|
||||
|
||||
`Waiting` migration uses a CTE with `LAG()` to derive `BlockedByTaskId` from `(parent_task_id, sort_order)`:
|
||||
|
||||
```sql
|
||||
WITH ordered AS (
|
||||
SELECT id,
|
||||
LAG(id) OVER (PARTITION BY parent_task_id ORDER BY sort_order, created_at) AS prev_id
|
||||
FROM tasks WHERE status='waiting'
|
||||
)
|
||||
UPDATE tasks SET status='queued',
|
||||
blocked_by_task_id=(SELECT prev_id FROM ordered WHERE ordered.id=tasks.id)
|
||||
WHERE id IN (SELECT id FROM ordered);
|
||||
```
|
||||
|
||||
Migration runs at worker startup via the existing `MigrateAsync` flow.
|
||||
|
||||
`Down()` is best-effort (local-only app). Reverse mapping is lossy: `Cancelled` → `Failed`, `BlockedByTaskId` → `Waiting`, planning fields → folded back into status.
|
||||
|
||||
### 7. Test strategy
|
||||
|
||||
New test fixtures (xUnit, real SQLite, real git where needed):
|
||||
|
||||
1. **`TaskStateServiceTests`** — happy path + reject for every transition; mock `IQueueWaker`, `HubBroadcaster`, `IPlanningChainCoordinator` and verify side-effect invocations; concurrency test (two parallel `StartRunningAsync` → exactly one wins).
|
||||
2. **`QueuePickerTests`** — filter logic (blocked, missing tag, future schedule, wrong status) and ordering (`sort_order, created_at`); two parallel pickers → exactly one claims a row.
|
||||
3. **`PlanningChainCoordinatorTests`** — `SetupChainAsync` produces correct (`Queued`, `BlockedBy`) layout; `OnChildFinishedAsync` unblocks the next child; child failure leaves remaining blocked, parent transitions to `Failed` after `TryCompleteParent`.
|
||||
4. **`PlanningEndToEndTests`** — regression for the original bug. `Active` parent + 3 drafts → `Finalize` → assert first child reaches `Running` within 200 ms with no manual `Wake`.
|
||||
5. **Existing tests** — anything seeding `task.Status = TaskStatus.Manual` or similar gets updated to new enum values or routed through `_state`.
|
||||
|
||||
Coverage target: state machine + queue picker at ≥90% branch coverage. Existing coverage levels preserved elsewhere.
|
||||
|
||||
### 8. Implementation slices
|
||||
|
||||
Each slice is one PR with green tests before the next starts.
|
||||
|
||||
1. **Slice 1 — Status model + migration.** New enum values, new columns, EF migration. Existing code mapped to new values mechanically (no behavior change).
|
||||
2. **Slice 2 — `TaskStateService`.** Service + interface + tests. Migrate TaskRunner, StaleTaskRecovery, ExternalMcp/PlanningMcp guards, TaskResetService. Mark `Mark*Async` repo helpers `internal`.
|
||||
3. **Slice 3 — `IQueueWaker` + `IQueuePicker`.** Extract from QueueService and Repo. Remove all manual `WakeQueue()` calls in app code.
|
||||
4. **Slice 4 — Planning flow consolidation.** Delete `FinalizePlanningAsync` from repo. `PlanningSessionManager.FinalizeAsync` orchestrates via state-service + ChainCoordinator. Rename `QueueSubtasksSequentiallyAsync` → `SetupChainAsync` (internal). E2E test green.
|
||||
5. **Slice 5 — `OverrideSlotService` + folder reorg.** Extract RunNow / ContinueTask. Move files to new folder structure. Update DI registration.
|
||||
6. **Slice 6 — Cleanup & docs.** Update `Worker/CLAUDE.md`, `docs/plan.md`. Remove dead helpers.
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- **EF migration on existing DBs.** Tested via integration tests that load a pre-migration fixture DB. `MigrateAsync` is already in production use, low risk.
|
||||
- **State-service becomes a god-object.** Mitigated by keeping it narrow: only status/phase/blocked-by writes, no business logic. Worktree, merge, and runner concerns stay in their own services.
|
||||
- **Two paths to `Running` (picker atomic, state-service for override).** Confirmed acceptable in brainstorming. Picker remains the only atomic-claim path; override slot is serialized by slot lock so non-atomic is safe.
|
||||
- **Waiting-migration CTE.** SQLite supports `LAG()` since 3.25. .NET 8's bundled SQLite is well above. Tested in migration unit tests.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None at design time. All knackpunkte resolved during brainstorming.
|
||||
@@ -0,0 +1,272 @@
|
||||
# Tabbed Settings + Prime Claude — Design
|
||||
|
||||
**Date:** 2026-04-28
|
||||
**Status:** Draft for review
|
||||
|
||||
## Goal
|
||||
|
||||
Two related UI changes:
|
||||
|
||||
1. Restructure the existing **Settings modal** from a single scrollable stack into a `TabControl` with focused tabs. Move the read-only "About" content out of Settings entirely, into a new modal accessible from the existing Help menu.
|
||||
2. Add a new **Prime Claude** tab where the user defines date-bounded daily schedules. At each scheduled time, the worker fires a single non-interactive `claude -p "ping" --max-turns 1` call to start Claude's 5-hour usage window early — "priming" the day.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
- Settings tabbed UI with 4 tabs: General, Worktrees, Files, Prime Claude.
|
||||
- New About modal opened from `MainWindow` Help menu.
|
||||
- New `PrimeSchedules` table, repository, EF migration.
|
||||
- New `PrimeScheduler` background service (event-driven, no polling).
|
||||
- New SignalR hub methods + client wiring.
|
||||
- Footer notification on prime fire (success/failure) via `StatusBarView`.
|
||||
- 30-minute catch-up window on app launch / wake.
|
||||
- Tests: scheduler unit tests, tab VM tests.
|
||||
|
||||
### Out of scope
|
||||
- Auto-start ClaudeDo at OS boot.
|
||||
- Multiple pings per day per schedule.
|
||||
- Per-schedule prompt customization (schema reserves the column for future use).
|
||||
- Holiday / calendar integration.
|
||||
- Toast notifications, sound, OS-level notifications.
|
||||
|
||||
## Settings tab layout
|
||||
|
||||
| Tab | Contents (existing sections, no field changes) |
|
||||
|---|---|
|
||||
| **General** | Claude Defaults: instructions, model, max turns, permission mode |
|
||||
| **Worktrees** | Strategy, central root, auto-cleanup, Cleanup button, Force-remove confirm flow |
|
||||
| **Files** | Agents (Restore default agents) + Prompts (System / Planning / Agent open-in-editor rows) |
|
||||
| **Prime Claude** | New — schedule list + add button (see below) |
|
||||
|
||||
- Window stays 580×760, custom title bar preserved.
|
||||
- Footer (Save / Cancel) preserved; Save iterates per-tab VMs.
|
||||
- Status / validation strip stays above the footer.
|
||||
- Tab strip uses the existing section-label style for headers (mono, 10pt, letter-spacing 1.4) so it visually matches the current aesthetic.
|
||||
|
||||
## About modal
|
||||
|
||||
New `AboutModalView` + `AboutModalViewModel`:
|
||||
- Same 4 rows as today's About section: Version, Data folder, Logs folder, Worker config — each with an Open button.
|
||||
- Compact dialog (~480×280), same chrome as `SettingsModalView`.
|
||||
- Wired into `MainWindow` Help menu as a new `<MenuItem Header="About…">` next to "Check for updates".
|
||||
- About content removed from `SettingsModalView` entirely (cleaner: not a setting).
|
||||
|
||||
## Prime Claude tab — UI
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Prime your Claude usage window each morning by firing a single │
|
||||
│ non-interactive `ping` call at a chosen time. Only runs while │
|
||||
│ ClaudeDo is open. If the app starts within 30 min of the target │
|
||||
│ time, the ping fires immediately (catch-up window). │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ ☑ May 5, 2026 → Jun 30, 2026 07:00 Mon–Fri last: today ✕│
|
||||
│ ☐ Jul 1, 2026 → Jul 7, 2026 09:30 All days — ✕│
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ [+ Add schedule] │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Per-row controls:
|
||||
- Enabled checkbox (`Enabled`)
|
||||
- Start date picker (`StartDate`)
|
||||
- End date picker (`EndDate`)
|
||||
- Time-of-day field (`TimeOfDay`, 24h, e.g. `07:00`)
|
||||
- Workdays-only checkbox (`WorkdaysOnly`)
|
||||
- Last run label (`{LastRunAt:g}` or `—` if null)
|
||||
- Delete button (✕, with inline confirm bar matching the Worktrees pattern)
|
||||
|
||||
`+ Add schedule` appends a new row pre-filled with: today, today + 30 days, `07:00`, `WorkdaysOnly = true`, `Enabled = true`.
|
||||
|
||||
Validation per row:
|
||||
- `StartDate <= EndDate`
|
||||
- `TimeOfDay` parses as `HH:mm`
|
||||
- `EndDate >= today` (else mark row disabled-looking + tooltip "expired")
|
||||
|
||||
Persistence: rows save with the rest of the modal on **Save**. On Save, `PrimeClaudeTabViewModel` diffs in-memory rows against the loaded snapshot and emits one hub call per change: `UpsertPrimeSchedule` for new/edited rows, `DeletePrimeSchedule` for removed rows. Cancel discards in-memory edits. No per-row autosave.
|
||||
|
||||
## Data model
|
||||
|
||||
New EF Core entity `PrimeScheduleEntity` in `ClaudeDo.Data/Models/`:
|
||||
|
||||
```csharp
|
||||
public class PrimeScheduleEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
public TimeSpan TimeOfDay { get; set; } // local clock
|
||||
public bool WorkdaysOnly { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
public DateTimeOffset? LastRunAt { get; set; }
|
||||
public string? PromptOverride { get; set; } // reserved, always null today
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
- New `PrimeScheduleConfiguration : IEntityTypeConfiguration<PrimeScheduleEntity>` in `Configuration/`.
|
||||
- New repository `PrimeScheduleRepository` matching the existing async + CancellationToken pattern. Methods: `ListAsync`, `GetAsync(id)`, `UpsertAsync(entity)`, `DeleteAsync(id)`, `UpdateLastRunAsync(id, when)`.
|
||||
- EF migration `AddPrimeSchedules` (auto-named per existing migration history).
|
||||
|
||||
## Worker scheduler — `PrimeScheduler`
|
||||
|
||||
New folder `ClaudeDo.Worker/Prime/`. Class hierarchy:
|
||||
|
||||
- `PrimeScheduler : BackgroundService` — event-driven loop.
|
||||
- `IPrimeRunner` / `PrimeRunner` — fires the actual `claude -p "ping" --max-turns 1` call. Injected so tests can fake it.
|
||||
- `IPrimeClock` / `PrimeClock` — `DateTimeOffset Now { get; }`. Faked in tests.
|
||||
- `PrimeSchedulerOptions` — `CatchUpWindow = TimeSpan.FromMinutes(30)`. Hardcoded today; typed for swappability.
|
||||
|
||||
### Loop
|
||||
|
||||
```text
|
||||
while not cancelled:
|
||||
next = ComputeNextDue(now) # null if no enabled schedules
|
||||
if next is null:
|
||||
await wait-on-signal # blocks until schedules change
|
||||
continue
|
||||
delay = max(0, next.At - now)
|
||||
try:
|
||||
await Task.Delay(delay, linkedToken) # cancellable by signal
|
||||
catch OperationCanceledException:
|
||||
continue # schedules changed → recompute
|
||||
await Fire(next.Schedule)
|
||||
```
|
||||
|
||||
`ComputeNextDue(now)`:
|
||||
- For each enabled schedule:
|
||||
- Determine the next eligible date `d >= today` within `[StartDate, EndDate]`, honoring `WorkdaysOnly`.
|
||||
- Skip the day if `LastRunAt.LocalDate == today` (already fired today).
|
||||
- Build `target = d.At(TimeOfDay)` in local time.
|
||||
- Apply catch-up: if `target < now <= target + 30min` and not already fired today, target = `now` (fire immediately).
|
||||
- If `target < now` (past catch-up window) and `d == today`, advance `d` to next eligible date.
|
||||
- Return the schedule with the smallest `target`.
|
||||
|
||||
### Signal source
|
||||
|
||||
`IPrimeScheduleSignal` — a thin abstraction wrapping a `CancellationTokenSource` reset. The hub calls `Signal()` on:
|
||||
- App start (initial recompute is implicit — service first-run computes immediately).
|
||||
- After `UpsertPrimeSchedule` / `DeletePrimeSchedule`.
|
||||
- After a successful fire (so the next-due is recomputed without polling).
|
||||
|
||||
### Fire
|
||||
|
||||
`PrimeRunner.FireAsync(schedule, ct)`:
|
||||
1. Resolve `claude` executable via existing `ClaudeProcess` discovery.
|
||||
2. Spawn with `cwd = Paths.AppDataRoot()`, args `["-p", "ping", "--max-turns", "1"]`. No worktree, no task entity, no list/tag side effects.
|
||||
3. Capture stdout/stderr; success = exit 0 within a 60s timeout.
|
||||
4. On finish: `await PrimeScheduleRepository.UpdateLastRunAsync(id, now)`, append a one-line summary to `~/.todo-app/logs/prime.log`, broadcast `PrimeFired(success, message, timestamp)` via `HubBroadcaster`.
|
||||
|
||||
Failure modes (network, auth, executable missing) → broadcast a failure message; `LastRunAt` still stamped so the day doesn't keep retrying.
|
||||
|
||||
## SignalR / IPC
|
||||
|
||||
### Hub methods (`WorkerHub`)
|
||||
|
||||
```csharp
|
||||
Task<IReadOnlyList<PrimeScheduleDto>> ListPrimeSchedules();
|
||||
Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto);
|
||||
Task DeletePrimeSchedule(Guid id);
|
||||
```
|
||||
|
||||
DTO mirrors entity minus `CreatedAt` (server-managed).
|
||||
|
||||
### Hub events (broadcast)
|
||||
|
||||
```csharp
|
||||
event PrimeFired(Guid scheduleId, bool success, string message, DateTimeOffset firedAt);
|
||||
```
|
||||
|
||||
The `scheduleId` lets an open Settings modal update the matching row's `LastRunAt` without a full reload. No separate `PrimeSchedulesChanged` event — Settings is the only writer, so the modal's own VM state is authoritative until Save.
|
||||
|
||||
`WorkerClient` adds matching async methods + the event handler.
|
||||
|
||||
## UI wiring
|
||||
|
||||
### ViewModel split
|
||||
|
||||
`SettingsModalViewModel` stops holding field properties directly and becomes a coordinator:
|
||||
|
||||
```csharp
|
||||
public sealed partial class SettingsModalViewModel
|
||||
{
|
||||
public GeneralSettingsTabViewModel General { get; }
|
||||
public WorktreesSettingsTabViewModel Worktrees { get; }
|
||||
public FilesSettingsTabViewModel Files { get; }
|
||||
public PrimeClaudeTabViewModel Prime { get; }
|
||||
|
||||
[RelayCommand] private async Task Save() { ... iterate tabs, call SaveAsync on each ... }
|
||||
}
|
||||
```
|
||||
|
||||
Each tab VM:
|
||||
- Owns its observable properties.
|
||||
- Has `Task LoadAsync()` and `Task SaveAsync()` (or returns a partial DTO the coordinator merges).
|
||||
- Owns its own validation, surfaces `ValidationError`.
|
||||
|
||||
`PrimeClaudeTabViewModel`:
|
||||
- `ObservableCollection<PrimeScheduleRowViewModel> Rows`
|
||||
- `[RelayCommand] AddSchedule()` / `RemoveSchedule(id)`
|
||||
- Subscribes to `WorkerClient.PrimeSchedulesChanged` / `PrimeFired` to keep rows fresh while modal is open.
|
||||
|
||||
### Footer notification
|
||||
|
||||
`StatusBarViewModel`:
|
||||
- New `string? PrimeStatus` property.
|
||||
- Subscribes to `WorkerClient.PrimeFired`.
|
||||
- On event: set `PrimeStatus`, start a `DispatcherTimer` for 5s, clear on tick.
|
||||
- `StatusBarView` gets a `TextBlock` bound to `PrimeStatus`, right-aligned, dim-foreground, only visible when non-empty.
|
||||
|
||||
Format: `"✓ Primed Claude at 07:01"` or `"⚠ Prime failed: <reason>"`.
|
||||
|
||||
### About wiring
|
||||
|
||||
- `MainWindowViewModel` adds `[RelayCommand] OpenAbout()` — opens `AboutModalView` via the existing dialog factory pattern.
|
||||
- `MainWindow.axaml` Help menu gains `<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>`.
|
||||
|
||||
## Tests
|
||||
|
||||
### `ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`
|
||||
|
||||
Real SQLite, fake `IPrimeClock`, fake `IPrimeRunner`. Cases:
|
||||
- Fires once at exact target time.
|
||||
- Fires immediately on startup if within catch-up window.
|
||||
- Skips firing if past catch-up window (waits for next eligible day).
|
||||
- Honors `WorkdaysOnly` (no fire on Sat/Sun).
|
||||
- Honors date range (no fire before StartDate, none after EndDate).
|
||||
- Idempotent: doesn't double-fire if `LastRunAt` is today.
|
||||
- Recomputes on signal (upsert mid-wait).
|
||||
- Disabling a schedule mid-wait recomputes.
|
||||
|
||||
### `ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`
|
||||
|
||||
Cases:
|
||||
- Add row appends with sensible defaults.
|
||||
- Remove row removes from collection.
|
||||
- Validation: StartDate > EndDate flags row as invalid.
|
||||
- Save serializes all rows to repository in one batch.
|
||||
- `PrimeFired` event updates the matching row's `LastRunAt`.
|
||||
|
||||
### `ClaudeDo.Ui.Tests/ViewModels/StatusBarViewModelTests.cs` (extend existing if present, else new)
|
||||
|
||||
- `PrimeFired` sets `PrimeStatus` and clears it after 5s (use a fake `IDispatcherTimer` or an injectable delay).
|
||||
|
||||
## Migration / rollout
|
||||
|
||||
- Single EF migration `AddPrimeSchedules`. Existing DBs upgrade on next launch via the existing migration runner (no manual step).
|
||||
- No data backfill — table starts empty. Users add schedules manually via the new tab.
|
||||
- Backwards compatibility for `AppSettingsEntity`: untouched.
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| App is closed at scheduled time | 30 min catch-up on launch; explicit copy in tab explains the limitation. |
|
||||
| Clock/timezone change while waiting | `Task.Delay` fires on monotonic time; recompute after each fire catches drift on next iteration. Acceptable for a 5h-window primer. |
|
||||
| Claude CLI hangs | 60s timeout on the spawn; failure stamped + broadcast. |
|
||||
| Multiple ClaudeDo instances on same machine | Out of scope (existing app already assumes single instance via fixed SignalR port). |
|
||||
| User edits schedule while scheduler is mid-fire | Fire completes, then signal triggers recompute. No race — `UpdateLastRunAsync` is the last write. |
|
||||
|
||||
## Open questions
|
||||
|
||||
None at design time. Implementation may surface small details (e.g. exact Avalonia controls for date/time pickers — likely `CalendarDatePicker` + a `TextBox` masked to `HH:mm` since Avalonia 12 has no built-in TimePicker on all platforms).
|
||||
@@ -7,6 +7,7 @@ using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
@@ -94,6 +95,8 @@ sealed class Program
|
||||
|
||||
// ViewModels
|
||||
sc.AddTransient<WorktreeModalViewModel>();
|
||||
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
||||
sc.AddTransient<PrimeClaudeTabViewModel>();
|
||||
sc.AddTransient<SettingsModalViewModel>();
|
||||
sc.AddTransient<MergeModalViewModel>();
|
||||
sc.AddTransient<ListSettingsModalViewModel>();
|
||||
|
||||
@@ -4,7 +4,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
|
||||
## Models
|
||||
|
||||
- **TaskEntity** — Id, ListId, Title, Description, Status (Manual|Queued|Running|Done|Failed), ScheduledFor, Result, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath (nullable overrides), IsStarred, IsMyDay, Notes
|
||||
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
|
||||
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath (all nullable)
|
||||
- **TagEntity** — Id (autoincrement), Name (unique)
|
||||
@@ -16,7 +16,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
|
||||
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim.
|
||||
|
||||
- **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync`, `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides)
|
||||
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `TryCompleteParentAsync`, `UpdateChildAsync`), tag management (`GetEffectiveTagsAsync` — union of task + list tags), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
|
||||
- **ListRepository** — CRUD, tag junction management, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
|
||||
- **TagRepository** — `GetOrCreateAsync` (idempotent)
|
||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
||||
@@ -35,7 +35,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `T
|
||||
|
||||
## Schema
|
||||
|
||||
Tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual".
|
||||
Tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual". The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`).
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -14,4 +14,9 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ClaudeDo.Worker" />
|
||||
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -18,6 +18,7 @@ public class ClaudeDoDbContext : DbContext
|
||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
|
||||
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
|
||||
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class PrimeScheduleEntityConfiguration : IEntityTypeConfiguration<PrimeScheduleEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PrimeScheduleEntity> builder)
|
||||
{
|
||||
builder.ToTable("prime_schedules");
|
||||
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
|
||||
|
||||
builder.Property(s => s.StartDate).HasColumnName("start_date").IsRequired();
|
||||
builder.Property(s => s.EndDate).HasColumnName("end_date").IsRequired();
|
||||
builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").IsRequired();
|
||||
builder.Property(s => s.WorkdaysOnly).HasColumnName("workdays_only").IsRequired().HasDefaultValue(true);
|
||||
builder.Property(s => s.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true);
|
||||
builder.Property(s => s.LastRunAt).HasColumnName("last_run_at");
|
||||
builder.Property(s => s.PromptOverride).HasColumnName("prompt_override");
|
||||
builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -9,32 +9,53 @@ namespace ClaudeDo.Data.Configuration;
|
||||
public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
{
|
||||
private static string StatusToString(TaskStatus v)
|
||||
=> v == TaskStatus.Manual ? "manual"
|
||||
: v == TaskStatus.Queued ? "queued"
|
||||
: v == TaskStatus.Running ? "running"
|
||||
: v == TaskStatus.Done ? "done"
|
||||
: v == TaskStatus.Failed ? "failed"
|
||||
: v == TaskStatus.Planning ? "planning"
|
||||
: v == TaskStatus.Planned ? "planned"
|
||||
: v == TaskStatus.Draft ? "draft"
|
||||
: v == TaskStatus.Waiting ? "waiting"
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
=> v switch
|
||||
{
|
||||
TaskStatus.Idle => "idle",
|
||||
TaskStatus.Queued => "queued",
|
||||
TaskStatus.Running => "running",
|
||||
TaskStatus.Done => "done",
|
||||
TaskStatus.Failed => "failed",
|
||||
TaskStatus.Cancelled => "cancelled",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v)),
|
||||
};
|
||||
|
||||
private static TaskStatus StatusFromString(string v)
|
||||
=> v == "manual" ? TaskStatus.Manual
|
||||
: v == "queued" ? TaskStatus.Queued
|
||||
: v == "running" ? TaskStatus.Running
|
||||
: v == "done" ? TaskStatus.Done
|
||||
: v == "failed" ? TaskStatus.Failed
|
||||
: v == "planning" ? TaskStatus.Planning
|
||||
: v == "planned" ? TaskStatus.Planned
|
||||
: v == "draft" ? TaskStatus.Draft
|
||||
: v == "waiting" ? TaskStatus.Waiting
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
=> v switch
|
||||
{
|
||||
"idle" => TaskStatus.Idle,
|
||||
"queued" => TaskStatus.Queued,
|
||||
"running" => TaskStatus.Running,
|
||||
"done" => TaskStatus.Done,
|
||||
"failed" => TaskStatus.Failed,
|
||||
"cancelled" => TaskStatus.Cancelled,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v)),
|
||||
};
|
||||
|
||||
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
|
||||
new(v => StatusToString(v), v => StatusFromString(v));
|
||||
|
||||
private static string PhaseToString(PlanningPhase v)
|
||||
=> v switch
|
||||
{
|
||||
PlanningPhase.None => "none",
|
||||
PlanningPhase.Active => "active",
|
||||
PlanningPhase.Finalized => "finalized",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v)),
|
||||
};
|
||||
|
||||
private static PlanningPhase PhaseFromString(string v)
|
||||
=> v switch
|
||||
{
|
||||
"none" => PlanningPhase.None,
|
||||
"active" => PlanningPhase.Active,
|
||||
"finalized" => PlanningPhase.Finalized,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v)),
|
||||
};
|
||||
|
||||
private static readonly ValueConverter<PlanningPhase, string> PhaseConverter =
|
||||
new(v => PhaseToString(v), v => PhaseFromString(v));
|
||||
|
||||
public void Configure(EntityTypeBuilder<TaskEntity> builder)
|
||||
{
|
||||
builder.ToTable("tasks");
|
||||
@@ -46,6 +67,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
builder.Property(t => t.Description).HasColumnName("description");
|
||||
builder.Property(t => t.Status).HasColumnName("status").IsRequired()
|
||||
.HasConversion(StatusConverter);
|
||||
builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired()
|
||||
.HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None);
|
||||
builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id");
|
||||
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
|
||||
builder.Property(t => t.Result).HasColumnName("result");
|
||||
builder.Property(t => t.LogPath).HasColumnName("log_path");
|
||||
@@ -73,6 +97,12 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
.HasForeignKey(t => t.ParentTaskId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// BlockedBy: predecessor in a sequential chain. SetNull on delete so child becomes pickable.
|
||||
builder.HasOne<TaskEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.BlockedByTaskId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.HasOne(t => t.List)
|
||||
.WithMany(l => l.Tasks)
|
||||
.HasForeignKey(t => t.ListId)
|
||||
@@ -97,5 +127,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
||||
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
|
||||
builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
|
||||
builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPlanningPhaseAndBlockedBy : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "blocked_by_task_id",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "planning_phase",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "none");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
column: "default_permission_mode",
|
||||
value: "auto");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_tasks_blocked_by",
|
||||
table: "tasks",
|
||||
column: "blocked_by_task_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_tasks_tasks_blocked_by_task_id",
|
||||
table: "tasks",
|
||||
column: "blocked_by_task_id",
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_tasks_tasks_blocked_by_task_id",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "idx_tasks_blocked_by",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "blocked_by_task_id",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "planning_phase",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
column: "default_permission_mode",
|
||||
value: "bypassPermissions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RetireLegacyTaskStatus : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// manual / draft -> idle
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'idle' WHERE status IN ('manual', 'draft');");
|
||||
|
||||
// planning -> idle + planning_phase=active
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'idle', planning_phase = 'active' WHERE status = 'planning';");
|
||||
|
||||
// planned -> idle + planning_phase=finalized
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'idle', planning_phase = 'finalized' WHERE status = 'planned';");
|
||||
|
||||
// waiting -> queued + blocked_by_task_id derived from sort_order chain.
|
||||
// SQLite 3.25+ supports window functions (LAG).
|
||||
migrationBuilder.Sql(@"
|
||||
WITH ordered AS (
|
||||
SELECT id,
|
||||
LAG(id) OVER (PARTITION BY parent_task_id ORDER BY sort_order, created_at) AS prev_id
|
||||
FROM tasks
|
||||
WHERE status = 'waiting'
|
||||
)
|
||||
UPDATE tasks
|
||||
SET status = 'queued',
|
||||
blocked_by_task_id = (SELECT prev_id FROM ordered WHERE ordered.id = tasks.id)
|
||||
WHERE id IN (SELECT id FROM ordered);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Best-effort and lossy: cancelled is folded back into failed,
|
||||
// (idle, finalized) -> planned, (idle, active) -> planning,
|
||||
// queued + blocked_by_task_id != null -> waiting.
|
||||
// Manual/Draft distinction is unrecoverable — anything previously
|
||||
// 'manual' or 'draft' stays 'idle' on the way back.
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'failed' WHERE status = 'cancelled';");
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'planned' WHERE status = 'idle' AND planning_phase = 'finalized';");
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'planning' WHERE status = 'idle' AND planning_phase = 'active';");
|
||||
migrationBuilder.Sql("UPDATE tasks SET status = 'waiting', blocked_by_task_id = NULL WHERE status = 'queued' AND blocked_by_task_id IS NOT NULL;");
|
||||
}
|
||||
}
|
||||
}
|
||||
679
src/ClaudeDo.Data/Migrations/20260428064951_AddPrimeSchedules.Designer.cs
generated
Normal file
679
src/ClaudeDo.Data/Migrations/20260428064951_AddPrimeSchedules.Designer.cs
generated
Normal file
@@ -0,0 +1,679 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260428064951_AddPrimeSchedules")]
|
||||
partial class AddPrimeSchedules
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.Property<bool>("WorkdaysOnly")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("workdays_only");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("tags", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1L,
|
||||
Name = "agent"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2L,
|
||||
Name = "manual"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("list_tags", b =>
|
||||
{
|
||||
b.Property<string>("list_id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("tag_id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("list_id", "tag_id");
|
||||
|
||||
b.HasIndex("tag_id");
|
||||
|
||||
b.ToTable("list_tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("task_tags", b =>
|
||||
{
|
||||
b.Property<string>("task_id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("tag_id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("task_id", "tag_id");
|
||||
|
||||
b.HasIndex("tag_id");
|
||||
|
||||
b.ToTable("task_tags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("list_tags", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("list_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("tag_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("task_tags", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("tag_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("task_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPrimeSchedules : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "prime_schedules",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
start_date = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
end_date = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
time_of_day = table.Column<TimeSpan>(type: "TEXT", nullable: false),
|
||||
workdays_only = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true),
|
||||
enabled = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true),
|
||||
last_run_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
|
||||
prompt_override = table.Column<string>(type: "TEXT", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_prime_schedules", x => x.id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "prime_schedules");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ namespace ClaudeDo.Data.Migrations
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "bypassPermissions",
|
||||
DefaultPermissionMode = "auto",
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
@@ -145,6 +145,53 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.Property<bool>("WorkdaysOnly")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("workdays_only");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -225,6 +272,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -285,6 +336,13 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
@@ -327,6 +385,9 @@ namespace ClaudeDo.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
@@ -519,6 +580,11 @@ namespace ClaudeDo.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
|
||||
14
src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs
Normal file
14
src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public sealed class PrimeScheduleEntity
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
public TimeSpan TimeOfDay { get; set; }
|
||||
public bool WorkdaysOnly { get; set; } = true;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public DateTimeOffset? LastRunAt { get; set; }
|
||||
public string? PromptOverride { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -2,15 +2,19 @@ namespace ClaudeDo.Data.Models;
|
||||
|
||||
public enum TaskStatus
|
||||
{
|
||||
Manual,
|
||||
Idle,
|
||||
Queued,
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
Planning,
|
||||
Planned,
|
||||
Draft,
|
||||
Waiting,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
public enum PlanningPhase
|
||||
{
|
||||
None,
|
||||
Active,
|
||||
Finalized,
|
||||
}
|
||||
|
||||
public sealed class TaskEntity
|
||||
@@ -19,7 +23,9 @@ public sealed class TaskEntity
|
||||
public required string ListId { get; init; }
|
||||
public required string Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public TaskStatus Status { get; set; } = TaskStatus.Manual;
|
||||
public TaskStatus Status { get; set; } = TaskStatus.Idle;
|
||||
public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None;
|
||||
public string? BlockedByTaskId { get; set; }
|
||||
public DateTime? ScheduledFor { get; set; }
|
||||
public string? Result { get; set; }
|
||||
public string? LogPath { get; set; }
|
||||
|
||||
58
src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs
Normal file
58
src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
// src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class PrimeScheduleRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public PrimeScheduleRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _context.PrimeSchedules.AsNoTracking()
|
||||
.OrderBy(s => s.StartDate)
|
||||
.ToListAsync(ct);
|
||||
return rows.OrderBy(s => s.StartDate).ThenBy(s => s.TimeOfDay).ToList();
|
||||
}
|
||||
|
||||
public async Task<PrimeScheduleEntity?> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
await _context.PrimeSchedules.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
|
||||
public async Task UpsertAsync(PrimeScheduleEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == entity.Id, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
_context.PrimeSchedules.Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.StartDate = entity.StartDate;
|
||||
existing.EndDate = entity.EndDate;
|
||||
existing.TimeOfDay = entity.TimeOfDay;
|
||||
existing.WorkdaysOnly = entity.WorkdaysOnly;
|
||||
existing.Enabled = entity.Enabled;
|
||||
existing.PromptOverride = entity.PromptOverride;
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var row = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
if (row is null) return;
|
||||
_context.PrimeSchedules.Remove(row);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task UpdateLastRunAsync(Guid id, DateTimeOffset when, CancellationToken ct = default)
|
||||
{
|
||||
var row = await _context.PrimeSchedules.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
if (row is null) return;
|
||||
row.LastRunAt = when;
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ public sealed class TaskRepository
|
||||
|
||||
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
var tracked = _context.Tasks.Local.FirstOrDefault(t => t.Id == entity.Id);
|
||||
if (tracked is not null && !ReferenceEquals(tracked, entity))
|
||||
_context.Entry(tracked).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
||||
_context.Tasks.Update(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
@@ -88,7 +91,7 @@ public sealed class TaskRepository
|
||||
|
||||
#region Status transitions
|
||||
|
||||
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
|
||||
internal async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
@@ -97,7 +100,7 @@ public sealed class TaskRepository
|
||||
.SetProperty(t => t.StartedAt, startedAt), ct);
|
||||
}
|
||||
|
||||
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
|
||||
internal async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
@@ -107,7 +110,7 @@ public sealed class TaskRepository
|
||||
.SetProperty(t => t.Result, result), ct);
|
||||
}
|
||||
|
||||
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
|
||||
internal async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
@@ -124,7 +127,7 @@ public sealed class TaskRepository
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.LogPath, logPath), ct);
|
||||
}
|
||||
|
||||
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
|
||||
internal async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
|
||||
{
|
||||
var resultText = "[stale] " + reason;
|
||||
var now = DateTime.UtcNow;
|
||||
@@ -141,7 +144,7 @@ public sealed class TaskRepository
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Manual)
|
||||
.SetProperty(t => t.Status, TaskStatus.Idle)
|
||||
.SetProperty(t => t.StartedAt, (DateTime?)null)
|
||||
.SetProperty(t => t.FinishedAt, (DateTime?)null)
|
||||
.SetProperty(t => t.Result, (string?)null), ct);
|
||||
@@ -194,6 +197,27 @@ public sealed class TaskRepository
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetTagsAsync(string taskId, IReadOnlyList<string> tagNames, CancellationToken ct = default)
|
||||
{
|
||||
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||
if (task is null) return;
|
||||
|
||||
task.Tags.Clear();
|
||||
|
||||
foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
|
||||
if (tag is null)
|
||||
{
|
||||
tag = new TagEntity { Name = name };
|
||||
_context.Tags.Add(tag);
|
||||
}
|
||||
task.Tags.Add(tag);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Tasks
|
||||
@@ -249,7 +273,7 @@ public sealed class TaskRepository
|
||||
ListId = parent.ListId,
|
||||
Title = title,
|
||||
Description = description,
|
||||
Status = TaskStatus.Draft,
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType,
|
||||
ParentTaskId = parentId,
|
||||
@@ -276,6 +300,41 @@ public sealed class TaskRepository
|
||||
return child;
|
||||
}
|
||||
|
||||
public async Task UpdateChildAsync(
|
||||
string taskId,
|
||||
string? title,
|
||||
string? description,
|
||||
string? commitType,
|
||||
IReadOnlyList<string>? tagNames,
|
||||
TaskStatus? status,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
if (title is not null) task.Title = title;
|
||||
if (description is not null) task.Description = description;
|
||||
if (commitType is not null) task.CommitType = commitType;
|
||||
if (status.HasValue) task.Status = status.Value;
|
||||
|
||||
if (tagNames is not null)
|
||||
{
|
||||
task.Tags.Clear();
|
||||
foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
|
||||
if (tag is null)
|
||||
{
|
||||
tag = new TagEntity { Name = name };
|
||||
_context.Tags.Add(tag);
|
||||
}
|
||||
task.Tags.Add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task UpdatePlanningTaskAsync(
|
||||
string taskId,
|
||||
string? title,
|
||||
@@ -299,15 +358,28 @@ public sealed class TaskRepository
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var affected = await _context.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status == TaskStatus.Manual)
|
||||
.Where(t => t.Id == taskId
|
||||
&& t.Status == TaskStatus.Idle
|
||||
&& t.PlanningPhase == PlanningPhase.None)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Planning)
|
||||
.SetProperty(t => t.PlanningPhase, PlanningPhase.Active)
|
||||
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
|
||||
|
||||
if (affected == 0) return null;
|
||||
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||
}
|
||||
|
||||
public async Task SetPlanningSessionTokenAsync(
|
||||
string taskId,
|
||||
string sessionToken,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
|
||||
}
|
||||
|
||||
public async Task UpdatePlanningSessionIdAsync(
|
||||
string parentId,
|
||||
string sessionId,
|
||||
@@ -329,49 +401,6 @@ public sealed class TaskRepository
|
||||
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
|
||||
}
|
||||
|
||||
public async Task<int> FinalizePlanningAsync(
|
||||
string parentId,
|
||||
bool queueAgentTasks,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
||||
|
||||
var parent = await _context.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.List).ThenInclude(l => l.Tags)
|
||||
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||
if (parent is null || parent.Status != TaskStatus.Planning)
|
||||
throw new InvalidOperationException($"Task {parentId} is not in Planning state.");
|
||||
|
||||
var listHasAgentTag = parent.List.Tags.Any(t => t.Name == "agent");
|
||||
|
||||
var drafts = await _context.Tasks
|
||||
.Include(t => t.Tags)
|
||||
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
|
||||
.ToListAsync(ct);
|
||||
|
||||
int count = 0;
|
||||
foreach (var draft in drafts)
|
||||
{
|
||||
var childHasAgentTag = draft.Tags.Any(t => t.Name == "agent");
|
||||
var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag);
|
||||
draft.Status = shouldQueue ? TaskStatus.Queued : TaskStatus.Manual;
|
||||
count++;
|
||||
}
|
||||
|
||||
var finalizedAt = DateTime.UtcNow;
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == parentId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Planned)
|
||||
.SetProperty(t => t.PlanningFinalizedAt, finalizedAt)
|
||||
.SetProperty(t => t.PlanningSessionToken, (string?)null), ct);
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<bool> DiscardPlanningAsync(
|
||||
string parentId,
|
||||
CancellationToken ct = default)
|
||||
@@ -381,20 +410,24 @@ public sealed class TaskRepository
|
||||
var parent = await _context.Tasks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||
if (parent is null || parent.Status != TaskStatus.Planning)
|
||||
if (parent is null || parent.PlanningPhase != PlanningPhase.Active)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Children created during the planning session are Status=Idle, PlanningPhase=None.
|
||||
await _context.Tasks
|
||||
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
|
||||
.Where(t => t.ParentTaskId == parentId
|
||||
&& t.Status == TaskStatus.Idle
|
||||
&& t.PlanningPhase == PlanningPhase.None)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == parentId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Manual)
|
||||
.SetProperty(t => t.Status, TaskStatus.Idle)
|
||||
.SetProperty(t => t.PlanningPhase, PlanningPhase.None)
|
||||
.SetProperty(t => t.PlanningSessionId, (string?)null)
|
||||
.SetProperty(t => t.PlanningSessionToken, (string?)null)
|
||||
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
|
||||
@@ -408,7 +441,7 @@ public sealed class TaskRepository
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||
if (parent is null || parent.Status != TaskStatus.Planned) return;
|
||||
if (parent is null || parent.PlanningPhase != PlanningPhase.Finalized) return;
|
||||
|
||||
var children = await _context.Tasks
|
||||
.Where(t => t.ParentTaskId == parentId)
|
||||
@@ -431,43 +464,4 @@ public sealed class TaskRepository
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Queue selection
|
||||
|
||||
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
||||
{
|
||||
// Atomic queue claim: UPDATE + RETURNING in one statement prevents TOCTOU races.
|
||||
// Uses raw SQL because EF cannot express UPDATE...RETURNING.
|
||||
// Includes both task-level and list-level "agent" tag so lists tagged "agent"
|
||||
// automatically enqueue all their tasks without per-task tagging.
|
||||
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — use the same format for comparison.
|
||||
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||
var result = await _context.Tasks.FromSqlRaw("""
|
||||
UPDATE tasks SET status = 'running'
|
||||
WHERE id = (
|
||||
SELECT t.id FROM tasks t
|
||||
WHERE t.status = 'queued'
|
||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM task_tags tt
|
||||
JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE tt.task_id = t.id AND tg.name = 'agent'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM list_tags lt
|
||||
JOIN tags tg ON tg.id = lt.tag_id
|
||||
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
|
||||
)
|
||||
)
|
||||
ORDER BY t.sort_order ASC, t.created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING *
|
||||
""", nowStr).ToListAsync(ct);
|
||||
|
||||
return result.FirstOrDefault();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
23
src/ClaudeDo.Ui/Converters/DateOnlyToDateTimeConverter.cs
Normal file
23
src/ClaudeDo.Ui/Converters/DateOnlyToDateTimeConverter.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public sealed class DateOnlyToDateTimeConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is DateOnly d)
|
||||
return d.ToDateTime(TimeOnly.MinValue);
|
||||
return null;
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is DateTime dt)
|
||||
return DateOnly.FromDateTime(dt);
|
||||
if (value is DateTimeOffset dto)
|
||||
return DateOnly.FromDateTime(dto.LocalDateTime);
|
||||
return DateOnly.FromDateTime(DateTime.Today);
|
||||
}
|
||||
}
|
||||
21
src/ClaudeDo.Ui/Converters/TimeSpanToHhmmConverter.cs
Normal file
21
src/ClaudeDo.Ui/Converters/TimeSpanToHhmmConverter.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public sealed class TimeSpanToHhmmConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is TimeSpan t ? $"{t.Hours:00}:{t.Minutes:00}" : "07:00";
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string s) return new TimeSpan(7, 0, 0);
|
||||
var parts = s.Split(':');
|
||||
if (parts.Length == 2 &&
|
||||
int.TryParse(parts[0], out var h) && h is >= 0 and <= 23 &&
|
||||
int.TryParse(parts[1], out var m) && m is >= 0 and <= 59)
|
||||
return new TimeSpan(h, m, 0);
|
||||
return new TimeSpan(7, 0, 0);
|
||||
}
|
||||
}
|
||||
17
src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs
Normal file
17
src/ClaudeDo.Ui/Services/IPrimeScheduleApi.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public interface IPrimeScheduleApi
|
||||
{
|
||||
Task<List<PrimeScheduleDto>> ListAsync();
|
||||
Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto);
|
||||
Task DeleteAsync(Guid id);
|
||||
}
|
||||
|
||||
public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi
|
||||
{
|
||||
private readonly WorkerClient _client;
|
||||
public WorkerPrimeScheduleApi(WorkerClient client) => _client = client;
|
||||
public Task<List<PrimeScheduleDto>> ListAsync() => _client.GetPrimeSchedulesAsync();
|
||||
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto) => _client.UpsertPrimeScheduleAsync(dto);
|
||||
public Task DeleteAsync(Guid id) => _client.DeletePrimeScheduleAsync(id);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
@@ -27,6 +28,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<List<AgentInfo>> GetAgentsAsync();
|
||||
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||
Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames);
|
||||
Task<List<string>> GetAllTagsAsync();
|
||||
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
||||
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||
|
||||
17
src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs
Normal file
17
src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed record PrimeScheduleDto(
|
||||
Guid Id,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
TimeSpan TimeOfDay,
|
||||
bool WorkdaysOnly,
|
||||
bool Enabled,
|
||||
DateTimeOffset? LastRunAt,
|
||||
string? PromptOverride);
|
||||
|
||||
public sealed record PrimeFiredEvent(
|
||||
Guid ScheduleId,
|
||||
bool Success,
|
||||
string Message,
|
||||
DateTimeOffset FiredAt);
|
||||
@@ -55,6 +55,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<string>? PlanningMergeAbortedEvent;
|
||||
public event Action<string>? PlanningCompletedEvent;
|
||||
|
||||
public event Action<PrimeFiredEvent>? PrimeFired;
|
||||
|
||||
public string? LastMergeAllTarget { get; private set; }
|
||||
|
||||
public WorkerClient(string signalRUrl)
|
||||
@@ -156,6 +158,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => PlanningCompletedEvent?.Invoke(planningTaskId));
|
||||
});
|
||||
|
||||
_hub.On<Guid, bool, string, DateTimeOffset>("PrimeFired", (id, ok, msg, when) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => PrimeFired?.Invoke(new PrimeFiredEvent(id, ok, msg, when)));
|
||||
});
|
||||
}
|
||||
|
||||
public Task StartAsync()
|
||||
@@ -329,6 +336,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("UpdateAppSettings", dto);
|
||||
}
|
||||
|
||||
public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
|
||||
{
|
||||
try { return await _hub.InvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules"); }
|
||||
catch { return new List<PrimeScheduleDto>(); }
|
||||
}
|
||||
|
||||
public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
|
||||
{
|
||||
try { return await _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public async Task DeletePrimeScheduleAsync(Guid id)
|
||||
{
|
||||
try { await _hub.InvokeAsync("DeletePrimeSchedule", id); }
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
public async Task UpdateListAsync(UpdateListDto dto)
|
||||
{
|
||||
await _hub.InvokeAsync("UpdateList", dto);
|
||||
@@ -356,6 +381,28 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("UpdateTaskAgentSettings", dto);
|
||||
}
|
||||
|
||||
public async Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
|
||||
{
|
||||
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
||||
}
|
||||
|
||||
public async Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames)
|
||||
{
|
||||
await _hub.InvokeAsync("SetTaskTags", taskId, tagNames.ToArray());
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAllTagsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<List<string>>("GetAllTags") ?? new List<string>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -57,6 +57,62 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
||||
|
||||
// Agent strip fields
|
||||
// Status editor (Details panel) — set freely; broadcast refreshes other panes.
|
||||
public System.Collections.ObjectModel.ObservableCollection<ClaudeDo.Data.Models.TaskStatus> StatusOptions { get; } = new()
|
||||
{
|
||||
ClaudeDo.Data.Models.TaskStatus.Idle,
|
||||
ClaudeDo.Data.Models.TaskStatus.Queued,
|
||||
ClaudeDo.Data.Models.TaskStatus.Running,
|
||||
ClaudeDo.Data.Models.TaskStatus.Done,
|
||||
ClaudeDo.Data.Models.TaskStatus.Failed,
|
||||
ClaudeDo.Data.Models.TaskStatus.Cancelled,
|
||||
};
|
||||
|
||||
private bool _suppressStatusSave;
|
||||
[ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _selectedStatus;
|
||||
|
||||
partial void OnSelectedStatusChanged(ClaudeDo.Data.Models.TaskStatus value)
|
||||
{
|
||||
if (_suppressStatusSave || Task is null) return;
|
||||
_ = SaveStatusAsync(value);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task SaveStatusAsync(ClaudeDo.Data.Models.TaskStatus value)
|
||||
{
|
||||
if (Task is null) return;
|
||||
try { await _worker.SetTaskStatusAsync(Task.Id, value); }
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
// Tag editor
|
||||
public ObservableCollection<string> Tags { get; } = new();
|
||||
public ObservableCollection<string> AvailableTags { get; } = new();
|
||||
[ObservableProperty] private string _newTagInput = "";
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task AddTagAsync()
|
||||
{
|
||||
if (Task is null) return;
|
||||
var name = NewTagInput?.Trim().ToLowerInvariant();
|
||||
NewTagInput = "";
|
||||
if (string.IsNullOrEmpty(name)) return;
|
||||
if (Tags.Contains(name)) return;
|
||||
var next = Tags.ToList();
|
||||
next.Add(name);
|
||||
try { await _worker.SetTaskTagsAsync(Task.Id, next); }
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task RemoveTagAsync(string? tagName)
|
||||
{
|
||||
if (Task is null || string.IsNullOrWhiteSpace(tagName)) return;
|
||||
if (!Tags.Contains(tagName)) return;
|
||||
var next = Tags.Where(t => t != tagName).ToList();
|
||||
try { await _worker.SetTaskTagsAsync(Task.Id, next); }
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))]
|
||||
private string _agentStatusLabel = "Idle";
|
||||
@@ -181,6 +237,44 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
// Set by the view so DeleteTaskCommand can show an error message
|
||||
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
||||
|
||||
private void ApplyTagsFromEntity(ClaudeDo.Data.Models.TaskEntity entity)
|
||||
{
|
||||
Tags.Clear();
|
||||
foreach (var t in entity.Tags) Tags.Add(t.Name);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RefreshAvailableTagsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var all = await _worker.GetAllTagsAsync();
|
||||
AvailableTags.Clear();
|
||||
foreach (var t in all) AvailableTags.Add(t);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RefreshTagsAndStatusAsync(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Tags)
|
||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity is null || Task?.Id != taskId) return;
|
||||
|
||||
_suppressStatusSave = true;
|
||||
try { SelectedStatus = entity.Status; }
|
||||
finally { _suppressStatusSave = false; }
|
||||
AgentStatusLabel = entity.Status.ToString();
|
||||
ApplyTagsFromEntity(entity);
|
||||
await RefreshAvailableTagsAsync();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, IServiceProvider services)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
@@ -229,6 +323,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
|
||||
_worker.TaskUpdatedEvent += taskId =>
|
||||
{
|
||||
if (Task?.Id == taskId) _ = RefreshTagsAndStatusAsync(taskId);
|
||||
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||||
};
|
||||
|
||||
@@ -409,6 +504,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
AgentStatusLabel = "Idle";
|
||||
LatestRunSessionId = null;
|
||||
ShowFailedActions = false;
|
||||
Tags.Clear();
|
||||
AvailableTags.Clear();
|
||||
NewTagInput = "";
|
||||
_suppressStatusSave = true;
|
||||
try { SelectedStatus = ClaudeDo.Data.Models.TaskStatus.Idle; }
|
||||
finally { _suppressStatusSave = false; }
|
||||
_suppressAgentSave = true;
|
||||
try
|
||||
{
|
||||
@@ -436,10 +537,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var subtaskRepo = new SubtaskRepository(ctx);
|
||||
|
||||
// Own query with Include so WorktreePath/BranchLine are populated.
|
||||
// Own query with Include so WorktreePath/BranchLine/Tags are populated.
|
||||
var entity = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Worktree)
|
||||
.Include(t => t.Tags)
|
||||
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (entity == null) return;
|
||||
@@ -455,6 +557,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
||||
AgentStatusLabel = entity.Status.ToString();
|
||||
_suppressStatusSave = true;
|
||||
try { SelectedStatus = entity.Status; }
|
||||
finally { _suppressStatusSave = false; }
|
||||
ApplyTagsFromEntity(entity);
|
||||
await RefreshAvailableTagsAsync();
|
||||
await LoadAgentSettingsAsync(entity, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -474,8 +581,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
foreach (var s in subs)
|
||||
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
||||
|
||||
if (entity.Status == ClaudeDo.Data.Models.TaskStatus.Planning ||
|
||||
entity.Status == ClaudeDo.Data.Models.TaskStatus.Planned)
|
||||
if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None)
|
||||
{
|
||||
await LoadPlanningChildrenAsync(row.Id, ct);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
@@ -7,6 +8,11 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
{
|
||||
public TaskRowViewModel()
|
||||
{
|
||||
Tags.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTags));
|
||||
}
|
||||
|
||||
public required string Id { get; init; }
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string _listName = "";
|
||||
@@ -15,6 +21,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _isMyDay;
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
[ObservableProperty] private TaskStatus _status;
|
||||
[ObservableProperty] private PlanningPhase _planningPhase;
|
||||
[ObservableProperty] private string? _branch;
|
||||
[ObservableProperty] private string? _diffStat;
|
||||
[ObservableProperty] private string? _liveTail;
|
||||
@@ -24,29 +31,32 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _dropHintAbove;
|
||||
[ObservableProperty] private bool _dropHintBelow;
|
||||
[ObservableProperty] private string? _parentTaskId;
|
||||
[ObservableProperty] private string? _blockedByTaskId;
|
||||
[ObservableProperty] private bool _isExpanded = true;
|
||||
[ObservableProperty] private bool _hasPlanningChildren;
|
||||
[ObservableProperty] private bool _hasQueuedSubtasks;
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
||||
|
||||
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
||||
public ObservableCollection<string> Tags { get; } = new();
|
||||
public int StepsCount { get; init; }
|
||||
public int StepsCompleted { get; init; }
|
||||
|
||||
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
||||
public bool IsPlanningParent => Status == TaskStatus.Planning
|
||||
|| Status == TaskStatus.Planned
|
||||
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|
||||
|| HasPlanningChildren;
|
||||
public bool IsDraft => Status == TaskStatus.Draft;
|
||||
public bool IsDraft => IsChild && Status == TaskStatus.Idle;
|
||||
|
||||
public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild;
|
||||
public bool CanResumeOrDiscardPlanning => Status == TaskStatus.Planning;
|
||||
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
|
||||
&& PlanningPhase == PlanningPhase.None
|
||||
&& !IsChild;
|
||||
public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active;
|
||||
|
||||
public string? PlanningBadge => Status switch
|
||||
public string? PlanningBadge => PlanningPhase switch
|
||||
{
|
||||
TaskStatus.Planning => "PLANNING",
|
||||
TaskStatus.Planned => "PLANNED",
|
||||
PlanningPhase.Active => "PLANNING",
|
||||
PlanningPhase.Finalized => "PLANNED",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -56,8 +66,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool HasSteps => StepsCount > 0;
|
||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||
public bool IsRunning => Status == TaskStatus.Running;
|
||||
public bool IsQueued => Status == TaskStatus.Queued;
|
||||
public bool IsWaiting => Status == TaskStatus.Waiting;
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||
public bool HasSchedule => ScheduledFor.HasValue;
|
||||
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
||||
|
||||
@@ -65,13 +76,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||
public string StepsText => $"{StepsCompleted}/{StepsCount} steps";
|
||||
|
||||
public string StatusChipClass => Status switch
|
||||
public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
|
||||
{
|
||||
TaskStatus.Running => "running",
|
||||
TaskStatus.Failed => "error",
|
||||
TaskStatus.Done => "review",
|
||||
TaskStatus.Queued => "queued",
|
||||
TaskStatus.Waiting => "waiting",
|
||||
(TaskStatus.Running, _) => "running",
|
||||
(TaskStatus.Failed, _) => "error",
|
||||
(TaskStatus.Done, _) => "review",
|
||||
(TaskStatus.Queued, true) => "waiting",
|
||||
(TaskStatus.Queued, false) => "queued",
|
||||
_ => "idle",
|
||||
};
|
||||
|
||||
@@ -82,13 +93,29 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsWaiting));
|
||||
OnPropertyChanged(nameof(HasLiveTail));
|
||||
OnPropertyChanged(nameof(IsPlanningParent));
|
||||
OnPropertyChanged(nameof(PlanningBadge));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
}
|
||||
|
||||
partial void OnPlanningPhaseChanged(PlanningPhase value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsPlanningParent));
|
||||
OnPropertyChanged(nameof(PlanningBadge));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
||||
}
|
||||
|
||||
partial void OnHasQueuedSubtasksChanged(bool value)
|
||||
=> OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
|
||||
partial void OnBlockedByTaskIdChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsWaiting));
|
||||
OnPropertyChanged(nameof(StatusChipClass));
|
||||
}
|
||||
|
||||
partial void OnParentTaskIdChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsChild));
|
||||
@@ -125,12 +152,23 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
IsStarred = t.IsStarred;
|
||||
IsMyDay = t.IsMyDay;
|
||||
Status = t.Status;
|
||||
PlanningPhase = t.PlanningPhase;
|
||||
Branch = t.Worktree?.BranchName;
|
||||
DiffStat = t.Worktree?.DiffStat;
|
||||
ScheduledFor = t.ScheduledFor;
|
||||
DiffAdditions = add;
|
||||
DiffDeletions = del;
|
||||
ParentTaskId = t.ParentTaskId;
|
||||
BlockedByTaskId = t.BlockedByTaskId;
|
||||
SetTags(t.Tags.Select(tag => tag.Name));
|
||||
}
|
||||
|
||||
public void SetTags(IEnumerable<string> names)
|
||||
{
|
||||
var snapshot = names.ToList();
|
||||
if (Tags.SequenceEqual(snapshot)) return;
|
||||
Tags.Clear();
|
||||
foreach (var n in snapshot) Tags.Add(n);
|
||||
}
|
||||
|
||||
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
|
||||
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
|
||||
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();
|
||||
public ObservableCollection<string> AllTags { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _newTaskTitle = "";
|
||||
[ObservableProperty] private TaskRowViewModel? _selectedTask;
|
||||
@@ -54,9 +55,22 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.TaskMessageEvent += OnWorkerTaskMessage;
|
||||
_ = RefreshAllTagsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshAllTagsAsync()
|
||||
{
|
||||
if (_worker is null) return;
|
||||
try
|
||||
{
|
||||
var tags = await _worker.GetAllTagsAsync();
|
||||
AllTags.Clear();
|
||||
foreach (var t in tags) AllTags.Add(t);
|
||||
}
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
private void OnWorkerTaskMessage(string taskId, string line)
|
||||
{
|
||||
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
||||
@@ -83,6 +97,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
var entity = await db.Tasks
|
||||
.Include(t => t.List)
|
||||
.Include(t => t.Worktree)
|
||||
.Include(t => t.Tags)
|
||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
|
||||
var existing = Items.FirstOrDefault(r => r.Id == taskId);
|
||||
@@ -100,6 +115,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
else return;
|
||||
}
|
||||
|
||||
// Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips.
|
||||
if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId))
|
||||
{
|
||||
var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId);
|
||||
if (parent is not null)
|
||||
parent.HasQueuedSubtasks = Items.Any(r =>
|
||||
r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting));
|
||||
}
|
||||
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
}
|
||||
@@ -162,12 +186,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
var all = await db.Tasks
|
||||
.Include(t => t.List)
|
||||
.Include(t => t.Worktree)
|
||||
.Include(t => t.Tags)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
static bool IsPlanningStatus(TaskStatus s) => s == TaskStatus.Planning || s == TaskStatus.Planned;
|
||||
static bool IsPlanningParent(TaskEntity t) => t.PlanningPhase != PlanningPhase.None;
|
||||
|
||||
IEnumerable<TaskEntity> filtered = list.Kind switch
|
||||
{
|
||||
@@ -176,10 +201,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
|
||||
ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t =>
|
||||
(t.Status == TaskStatus.Queued && t.ParentTaskId == null) ||
|
||||
(IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && (c.Status == TaskStatus.Queued || c.Status == TaskStatus.Waiting)))),
|
||||
(IsPlanningParent(t) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Queued))),
|
||||
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t =>
|
||||
(t.Status == TaskStatus.Running && t.ParentTaskId == null) ||
|
||||
(IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Running))),
|
||||
(IsPlanningParent(t) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Running))),
|
||||
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null),
|
||||
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
|
||||
_ => Enumerable.Empty<TaskEntity>(),
|
||||
@@ -207,6 +232,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
if (parentsWithChildren.Contains(r.Id))
|
||||
r.HasPlanningChildren = true;
|
||||
|
||||
// Mark planning parents whose children are currently queued/waiting,
|
||||
// so the dequeue affordance is visible on the parent row.
|
||||
var parentsWithQueuedKids = Items
|
||||
.Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId)
|
||||
&& (r.IsQueued || r.IsWaiting))
|
||||
.Select(r => r.ParentTaskId!)
|
||||
.ToHashSet();
|
||||
foreach (var r in Items)
|
||||
r.HasQueuedSubtasks = parentsWithQueuedKids.Contains(r.Id);
|
||||
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
}
|
||||
@@ -225,7 +260,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
.Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId))
|
||||
.GroupBy(r => r.ParentTaskId!)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild))
|
||||
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild
|
||||
&& r.PlanningPhase == PlanningPhase.Finalized
|
||||
&& !r.Done))
|
||||
{
|
||||
if (_expandedState.ContainsKey(parent.Id)) continue;
|
||||
if (childrenByParent.TryGetValue(parent.Id, out var kids)
|
||||
@@ -418,7 +455,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity != null)
|
||||
{
|
||||
entity.Status = row.Done ? TaskStatus.Done : TaskStatus.Manual;
|
||||
entity.Status = row.Done ? TaskStatus.Done : TaskStatus.Idle;
|
||||
row.Status = entity.Status;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
@@ -441,14 +478,44 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
|
||||
{
|
||||
if (_worker is null) return;
|
||||
try { await _worker.SetTaskStatusAsync(row.Id, status); }
|
||||
catch { /* offline; broadcast won't fire */ }
|
||||
}
|
||||
|
||||
public async Task ToggleTagOnRowAsync(TaskRowViewModel row, string tagName)
|
||||
{
|
||||
if (_worker is null) return;
|
||||
var name = tagName.Trim().ToLowerInvariant();
|
||||
if (name.Length == 0) return;
|
||||
var current = row.Tags.ToList();
|
||||
var next = current.Contains(name)
|
||||
? current.Where(t => t != name).ToList()
|
||||
: current.Append(name).ToList();
|
||||
try
|
||||
{
|
||||
await _worker.SetTaskTagsAsync(row.Id, next);
|
||||
await RefreshAllTagsAsync();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.IsRunning) return;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
var entity = await db.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity is null) return;
|
||||
entity.Status = TaskStatus.Queued;
|
||||
// Worker queue picker requires the "agent" tag — attach it on explicit enqueue.
|
||||
if (!entity.Tags.Any(t => t.Name == "agent"))
|
||||
{
|
||||
var agentTag = await db.Tags.FirstOrDefaultAsync(t => t.Name == "agent");
|
||||
if (agentTag is not null) entity.Tags.Add(agentTag);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
row.Status = TaskStatus.Queued;
|
||||
if (_worker is not null)
|
||||
@@ -467,9 +534,36 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity is null) return;
|
||||
entity.Status = TaskStatus.Manual;
|
||||
|
||||
// Cascade to queued children when present — covers both planning parents
|
||||
// (PlanningPhase != None) and bare parents that have a manually-queued
|
||||
// chain. The X button's visibility is gated by the same condition
|
||||
// (HasQueuedSubtasks), so the handler matches what the user can see.
|
||||
var queuedChildren = await db.Tasks
|
||||
.Where(t => t.ParentTaskId == row.Id && t.Status == TaskStatus.Queued)
|
||||
.ToListAsync();
|
||||
foreach (var c in queuedChildren)
|
||||
{
|
||||
c.Status = TaskStatus.Idle;
|
||||
c.BlockedByTaskId = null;
|
||||
}
|
||||
if (entity.Status == TaskStatus.Queued)
|
||||
entity.Status = TaskStatus.Idle;
|
||||
await db.SaveChangesAsync();
|
||||
row.Status = TaskStatus.Manual;
|
||||
|
||||
foreach (var c in queuedChildren)
|
||||
{
|
||||
var childRow = Items.FirstOrDefault(r => r.Id == c.Id);
|
||||
if (childRow is not null)
|
||||
{
|
||||
childRow.Status = TaskStatus.Idle;
|
||||
childRow.BlockedByTaskId = null;
|
||||
}
|
||||
}
|
||||
if (row.Status == TaskStatus.Queued)
|
||||
row.Status = TaskStatus.Idle;
|
||||
row.HasQueuedSubtasks = false;
|
||||
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
@@ -510,7 +604,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
[RelayCommand]
|
||||
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.Status != TaskStatus.Manual) return;
|
||||
if (row is null) return;
|
||||
if (row.Status != TaskStatus.Idle || row.PlanningPhase != PlanningPhase.None) return;
|
||||
ForegroundHelper.AllowAny();
|
||||
try { await _worker!.StartPlanningSessionAsync(row.Id); }
|
||||
catch { }
|
||||
|
||||
@@ -8,6 +8,7 @@ using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using ClaudeDo.Ui.ViewModels.Planning;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -35,6 +36,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
// Set by MainWindow to open the conflict resolution dialog.
|
||||
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
|
||||
|
||||
// Set by MainWindow to open the About dialog.
|
||||
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
|
||||
|
||||
[ObservableProperty] private bool _isUpdateBannerVisible;
|
||||
[ObservableProperty] private string? _updateBannerLatestVersion;
|
||||
[ObservableProperty] private string? _inlineUpdateStatus;
|
||||
@@ -57,6 +61,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
|
||||
private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false };
|
||||
|
||||
[ObservableProperty] private string? _primeStatus;
|
||||
private readonly System.Timers.Timer _primeStatusTimer = new(5_000) { AutoReset = false };
|
||||
|
||||
[RelayCommand]
|
||||
private void FocusSearch() => Lists?.RequestFocusSearch();
|
||||
|
||||
@@ -91,6 +98,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
WorkerLogText = null;
|
||||
}
|
||||
|
||||
private void OnPrimeFired(PrimeFiredEvent evt)
|
||||
{
|
||||
var when = evt.FiredAt.LocalDateTime.ToString("HH:mm");
|
||||
PrimeStatus = evt.Success
|
||||
? $"✓ Primed Claude at {when}"
|
||||
: $"⚠ Prime failed: {evt.Message}";
|
||||
_primeStatusTimer.Stop();
|
||||
_primeStatusTimer.Start();
|
||||
}
|
||||
|
||||
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
|
||||
{
|
||||
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
|
||||
@@ -173,6 +190,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
};
|
||||
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
||||
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
|
||||
Worker.PrimeFired += OnPrimeFired;
|
||||
_clearTimer.Elapsed += (_, _) =>
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
@@ -180,6 +198,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
else
|
||||
Dispatcher.UIThread.Post(ClearWorkerLog);
|
||||
};
|
||||
_primeStatusTimer.Elapsed += (_, _) =>
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() => PrimeStatus = null);
|
||||
_ = Lists.LoadAsync();
|
||||
_updateCheck.PropertyChanged += (_, e) =>
|
||||
{
|
||||
@@ -222,6 +242,13 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
if (InlineUpdateStatus == text) InlineUpdateStatus = null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenAbout()
|
||||
{
|
||||
var vm = new AboutModalViewModel();
|
||||
if (ShowAboutModal is not null) await ShowAboutModal(vm);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CheckForUpdatesAsync()
|
||||
{
|
||||
|
||||
34
src/ClaudeDo.Ui/ViewModels/Modals/AboutModalViewModel.cs
Normal file
34
src/ClaudeDo.Ui/ViewModels/Modals/AboutModalViewModel.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using ClaudeDo.Data;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class AboutModalViewModel : ViewModelBase
|
||||
{
|
||||
public string AppVersion { get; } =
|
||||
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
|
||||
public string DataFolderPath { get; } = Paths.AppDataRoot();
|
||||
public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs");
|
||||
public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
[RelayCommand] private void Close() => CloseAction?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
try
|
||||
{
|
||||
var target = File.Exists(path) ? path : (Directory.Exists(path) ? path : null);
|
||||
if (target is null) return;
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{target}\"") { UseShellExecute = true });
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Diagnostics;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
public sealed partial class FilesSettingsTabViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
|
||||
public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System);
|
||||
public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning);
|
||||
public string AgentPromptPath { get; } = PromptFiles.PathFor(PromptKind.Agent);
|
||||
|
||||
public FilesSettingsTabViewModel(WorkerClient worker) => _worker = worker;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestoreDefaultAgents()
|
||||
{
|
||||
IsBusy = true; StatusMessage = "";
|
||||
try
|
||||
{
|
||||
var r = await _worker.RestoreDefaultAgentsAsync();
|
||||
if (r is null) StatusMessage = "Worker offline.";
|
||||
else if (r.Copied == 0 && r.Skipped == 0) StatusMessage = "No default agents bundled.";
|
||||
else if (r.Copied == 0) StatusMessage = "All default agents already present.";
|
||||
else StatusMessage = $"Restored {r.Copied} default agent(s).";
|
||||
await _worker.RefreshAgentsAsync();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Restore failed: {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenPrompt(string? kindName)
|
||||
{
|
||||
if (!Enum.TryParse<PromptKind>(kindName, ignoreCase: true, out var kind)) return;
|
||||
try
|
||||
{
|
||||
PromptFiles.EnsureExists(kind);
|
||||
var path = PromptFiles.PathFor(kind);
|
||||
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Open failed: {ex.Message}"; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string _defaultClaudeInstructions = "";
|
||||
[ObservableProperty] private string _defaultModel = "sonnet";
|
||||
[ObservableProperty] private int _defaultMaxTurns = 100;
|
||||
[ObservableProperty] private string _defaultPermissionMode = "auto";
|
||||
|
||||
public IReadOnlyList<string> Models { get; } = new[] { "opus", "sonnet", "haiku" };
|
||||
public IReadOnlyList<string> PermissionModes { get; } = new[]
|
||||
{ "auto", "bypassPermissions", "acceptEdits", "plan", "default" };
|
||||
|
||||
public string? Validate()
|
||||
{
|
||||
if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200)
|
||||
return "Max turns must be between 1 and 200.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
public sealed partial class PrimeClaudeTabViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IPrimeScheduleApi _api;
|
||||
private readonly HashSet<Guid> _initialIds = new();
|
||||
|
||||
public ObservableCollection<PrimeScheduleRowViewModel> Rows { get; } = new();
|
||||
|
||||
public PrimeClaudeTabViewModel(IPrimeScheduleApi api) => _api = api;
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
Rows.Clear();
|
||||
_initialIds.Clear();
|
||||
var list = await _api.ListAsync();
|
||||
foreach (var dto in list)
|
||||
{
|
||||
Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: true));
|
||||
_initialIds.Add(dto.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public string? Validate()
|
||||
{
|
||||
foreach (var r in Rows)
|
||||
{
|
||||
if (r.StartDate > r.EndDate)
|
||||
return $"Schedule {r.TimeOfDay:hh\\:mm}: start date is after end date.";
|
||||
if (r.TimeOfDay < TimeSpan.Zero || r.TimeOfDay >= TimeSpan.FromDays(1))
|
||||
return "Time must be between 00:00 and 23:59.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task SaveAsync()
|
||||
{
|
||||
var keepIds = Rows.Select(r => r.Id).ToHashSet();
|
||||
foreach (var removed in _initialIds.Where(id => !keepIds.Contains(id)).ToList())
|
||||
await _api.DeleteAsync(removed);
|
||||
foreach (var r in Rows)
|
||||
await _api.UpsertAsync(r.ToDto());
|
||||
_initialIds.Clear();
|
||||
foreach (var id in keepIds) _initialIds.Add(id);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddSchedule()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var dto = new PrimeScheduleDto(
|
||||
Id: Guid.NewGuid(),
|
||||
StartDate: today,
|
||||
EndDate: today.AddDays(30),
|
||||
TimeOfDay: new TimeSpan(7, 0, 0),
|
||||
WorkdaysOnly: true,
|
||||
Enabled: true,
|
||||
LastRunAt: null,
|
||||
PromptOverride: null);
|
||||
Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: false));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveSchedule(PrimeScheduleRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
Rows.Remove(row);
|
||||
}
|
||||
|
||||
public void ApplyFiredEvent(PrimeFiredEvent evt)
|
||||
{
|
||||
var row = Rows.FirstOrDefault(r => r.Id == evt.ScheduleId);
|
||||
if (row is null) return;
|
||||
if (evt.Success) row.LastRunAt = evt.FiredAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
|
||||
{
|
||||
public Guid Id { get; }
|
||||
public bool IsExisting { get; }
|
||||
|
||||
[ObservableProperty] private bool _enabled;
|
||||
[ObservableProperty] private DateOnly _startDate;
|
||||
[ObservableProperty] private DateOnly _endDate;
|
||||
[ObservableProperty] private TimeSpan _timeOfDay;
|
||||
[ObservableProperty] private bool _workdaysOnly;
|
||||
[ObservableProperty] private DateTimeOffset? _lastRunAt;
|
||||
|
||||
public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—";
|
||||
|
||||
partial void OnLastRunAtChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastRunLabel));
|
||||
|
||||
public PrimeScheduleRowViewModel(PrimeScheduleDto dto, bool isExisting)
|
||||
{
|
||||
Id = dto.Id;
|
||||
IsExisting = isExisting;
|
||||
Enabled = dto.Enabled;
|
||||
StartDate = dto.StartDate;
|
||||
EndDate = dto.EndDate;
|
||||
TimeOfDay = dto.TimeOfDay;
|
||||
WorkdaysOnly = dto.WorkdaysOnly;
|
||||
LastRunAt = dto.LastRunAt;
|
||||
}
|
||||
|
||||
public PrimeScheduleDto ToDto() =>
|
||||
new(Id, StartDate, EndDate, TimeOfDay, WorkdaysOnly, Enabled, LastRunAt, null);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
|
||||
[ObservableProperty] private string _worktreeStrategy = "sibling";
|
||||
[ObservableProperty] private string? _centralWorktreeRoot;
|
||||
[ObservableProperty] private bool _worktreeAutoCleanupEnabled;
|
||||
[ObservableProperty] private int _worktreeAutoCleanupDays = 7;
|
||||
|
||||
[ObservableProperty] private bool _showResetConfirm;
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
|
||||
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
|
||||
|
||||
public WorktreesSettingsTabViewModel(WorkerClient worker) => _worker = worker;
|
||||
|
||||
public string? Validate()
|
||||
{
|
||||
if (WorktreeAutoCleanupEnabled && (WorktreeAutoCleanupDays < 1 || WorktreeAutoCleanupDays > 365))
|
||||
return "Cleanup days must be between 1 and 365.";
|
||||
if (WorktreeStrategy == "central")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CentralWorktreeRoot))
|
||||
return "Central worktree root is required for Central strategy.";
|
||||
if (!Directory.Exists(CentralWorktreeRoot))
|
||||
return $"Directory not found: {CentralWorktreeRoot}";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CleanupWorktrees()
|
||||
{
|
||||
IsBusy = true; StatusMessage = "";
|
||||
try
|
||||
{
|
||||
var r = await _worker.CleanupFinishedWorktreesAsync();
|
||||
StatusMessage = r is null ? "Worker offline." : $"Removed {r.Removed} worktree(s).";
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand] private void RequestResetConfirm() => ShowResetConfirm = true;
|
||||
[RelayCommand] private void CancelResetConfirm() => ShowResetConfirm = false;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ConfirmResetAll()
|
||||
{
|
||||
ShowResetConfirm = false; IsBusy = true; StatusMessage = "";
|
||||
try
|
||||
{
|
||||
var r = await _worker.ResetAllWorktreesAsync();
|
||||
if (r is null) StatusMessage = "Worker offline.";
|
||||
else if (r.Blocked) StatusMessage = $"Cannot force-remove: {r.RunningTasks} task(s) still running. Cancel them first.";
|
||||
else StatusMessage = $"Removed {r.Removed} worktree(s) from {r.TasksAffected} task(s).";
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
@@ -12,41 +10,24 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
|
||||
[ObservableProperty] private string _defaultClaudeInstructions = "";
|
||||
[ObservableProperty] private string _defaultModel = "sonnet";
|
||||
[ObservableProperty] private int _defaultMaxTurns = 100;
|
||||
[ObservableProperty] private string _defaultPermissionMode = "auto";
|
||||
[ObservableProperty] private string _worktreeStrategy = "sibling";
|
||||
[ObservableProperty] private string? _centralWorktreeRoot;
|
||||
[ObservableProperty] private bool _worktreeAutoCleanupEnabled;
|
||||
[ObservableProperty] private int _worktreeAutoCleanupDays = 7;
|
||||
public GeneralSettingsTabViewModel General { get; }
|
||||
public WorktreesSettingsTabViewModel Worktrees { get; }
|
||||
public FilesSettingsTabViewModel Files { get; }
|
||||
public PrimeClaudeTabViewModel Prime { get; }
|
||||
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private bool _showResetConfirm;
|
||||
[ObservableProperty] private string _validationError = "";
|
||||
|
||||
public IReadOnlyList<string> Models { get; } = new[] { "opus", "sonnet", "haiku" };
|
||||
public IReadOnlyList<string> PermissionModes { get; } = new[]
|
||||
{ "auto", "bypassPermissions", "acceptEdits", "plan", "default" };
|
||||
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
|
||||
|
||||
public string AppVersion { get; } =
|
||||
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
|
||||
|
||||
public string DataFolderPath { get; } = Paths.AppDataRoot();
|
||||
public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs");
|
||||
public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||
|
||||
public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System);
|
||||
public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning);
|
||||
public string AgentPromptPath { get; } = PromptFiles.PathFor(PromptKind.Agent);
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public SettingsModalViewModel(WorkerClient worker)
|
||||
public SettingsModalViewModel(WorkerClient worker, PrimeClaudeTabViewModel prime)
|
||||
{
|
||||
_worker = worker;
|
||||
General = new GeneralSettingsTabViewModel();
|
||||
Worktrees = new WorktreesSettingsTabViewModel(worker);
|
||||
Files = new FilesSettingsTabViewModel(worker);
|
||||
Prime = prime;
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
@@ -57,166 +38,48 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
var dto = await _worker.GetAppSettingsAsync();
|
||||
if (dto is not null)
|
||||
{
|
||||
DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "";
|
||||
DefaultModel = dto.DefaultModel ?? "sonnet";
|
||||
DefaultMaxTurns = dto.DefaultMaxTurns;
|
||||
DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto";
|
||||
WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
|
||||
CentralWorktreeRoot = dto.CentralWorktreeRoot;
|
||||
WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
|
||||
WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays;
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = "Worker offline — settings read-only.";
|
||||
General.DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "";
|
||||
General.DefaultModel = dto.DefaultModel ?? "sonnet";
|
||||
General.DefaultMaxTurns = dto.DefaultMaxTurns;
|
||||
General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto";
|
||||
Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
|
||||
Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
|
||||
Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
|
||||
Worktrees.WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays;
|
||||
}
|
||||
else StatusMessage = "Worker offline — settings read-only.";
|
||||
|
||||
await Prime.LoadAsync();
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
private bool Validate()
|
||||
{
|
||||
if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200)
|
||||
{ ValidationError = "Max turns must be between 1 and 200."; return false; }
|
||||
|
||||
if (WorktreeAutoCleanupEnabled &&
|
||||
(WorktreeAutoCleanupDays < 1 || WorktreeAutoCleanupDays > 365))
|
||||
{ ValidationError = "Cleanup days must be between 1 and 365."; return false; }
|
||||
|
||||
if (WorktreeStrategy == "central")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CentralWorktreeRoot))
|
||||
{ ValidationError = "Central worktree root is required for Central strategy."; return false; }
|
||||
if (!Directory.Exists(CentralWorktreeRoot))
|
||||
{ ValidationError = $"Directory not found: {CentralWorktreeRoot}"; return false; }
|
||||
}
|
||||
|
||||
ValidationError = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Save()
|
||||
{
|
||||
if (!Validate()) return;
|
||||
var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate();
|
||||
if (err is not null) { ValidationError = err; return; }
|
||||
ValidationError = "";
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var dto = new AppSettingsDto(
|
||||
DefaultClaudeInstructions ?? "",
|
||||
DefaultModel ?? "sonnet",
|
||||
DefaultMaxTurns,
|
||||
DefaultPermissionMode ?? "auto",
|
||||
WorktreeStrategy ?? "sibling",
|
||||
string.IsNullOrWhiteSpace(CentralWorktreeRoot) ? null : CentralWorktreeRoot,
|
||||
WorktreeAutoCleanupEnabled,
|
||||
WorktreeAutoCleanupDays);
|
||||
General.DefaultClaudeInstructions ?? "",
|
||||
General.DefaultModel ?? "sonnet",
|
||||
General.DefaultMaxTurns,
|
||||
General.DefaultPermissionMode ?? "auto",
|
||||
Worktrees.WorktreeStrategy ?? "sibling",
|
||||
string.IsNullOrWhiteSpace(Worktrees.CentralWorktreeRoot) ? null : Worktrees.CentralWorktreeRoot,
|
||||
Worktrees.WorktreeAutoCleanupEnabled,
|
||||
Worktrees.WorktreeAutoCleanupDays);
|
||||
await _worker.UpdateAppSettingsAsync(dto);
|
||||
await Prime.SaveAsync();
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Save failed: {ex.Message}";
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Save failed: {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel() => CloseAction?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CleanupWorktrees()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "";
|
||||
try
|
||||
{
|
||||
var result = await _worker.CleanupFinishedWorktreesAsync();
|
||||
StatusMessage = result is null
|
||||
? "Worker offline."
|
||||
: $"Removed {result.Removed} worktree(s).";
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RequestResetConfirm() => ShowResetConfirm = true;
|
||||
|
||||
[RelayCommand]
|
||||
private void CancelResetConfirm() => ShowResetConfirm = false;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ConfirmResetAll()
|
||||
{
|
||||
ShowResetConfirm = false;
|
||||
IsBusy = true;
|
||||
StatusMessage = "";
|
||||
try
|
||||
{
|
||||
var result = await _worker.ResetAllWorktreesAsync();
|
||||
if (result is null)
|
||||
StatusMessage = "Worker offline.";
|
||||
else if (result.Blocked)
|
||||
StatusMessage = $"Cannot force-remove: {result.RunningTasks} task(s) still running. Cancel them first.";
|
||||
else
|
||||
StatusMessage = $"Removed {result.Removed} worktree(s) from {result.TasksAffected} task(s).";
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestoreDefaultAgents()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "";
|
||||
try
|
||||
{
|
||||
var result = await _worker.RestoreDefaultAgentsAsync();
|
||||
if (result is null)
|
||||
StatusMessage = "Worker offline.";
|
||||
else if (result.Copied == 0 && result.Skipped == 0)
|
||||
StatusMessage = "No default agents bundled.";
|
||||
else if (result.Copied == 0)
|
||||
StatusMessage = "All default agents already present.";
|
||||
else
|
||||
StatusMessage = $"Restored {result.Copied} default agent(s).";
|
||||
|
||||
await _worker.RefreshAgentsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Restore failed: {ex.Message}";
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
try
|
||||
{
|
||||
var target = File.Exists(path) ? path : (Directory.Exists(path) ? path : null);
|
||||
if (target is null) return;
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{target}\"") { UseShellExecute = true });
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenPrompt(string? kindName)
|
||||
{
|
||||
if (!Enum.TryParse<PromptKind>(kindName, ignoreCase: true, out var kind)) return;
|
||||
try
|
||||
{
|
||||
PromptFiles.EnsureExists(kind);
|
||||
var path = PromptFiles.PathFor(kind);
|
||||
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Open failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
[RelayCommand] private void Cancel() => CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
167
src/ClaudeDo.Ui/Views/Controls/ThemedDatePicker.axaml
Normal file
167
src/ClaudeDo.Ui/Views/Controls/ThemedDatePicker.axaml
Normal file
@@ -0,0 +1,167 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="ClaudeDo.Ui.Views.Controls.ThemedDatePicker"
|
||||
x:Name="Root">
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="ToggleButton.trigger">
|
||||
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="{StaticResource InputCornerRadius}"/>
|
||||
<Setter Property="Padding" Value="10,6"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||
<Setter Property="MinHeight" Value="30"/>
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.trigger:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource Surface2Brush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LineBrightBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.trigger:checked /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource Surface2Brush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.quick">
|
||||
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextDimBrush}"/>
|
||||
<Setter Property="CornerRadius" Value="999"/>
|
||||
<Setter Property="Padding" Value="10,3"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="MinHeight" Value="22"/>
|
||||
</Style>
|
||||
<Style Selector="Button.quick:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource Surface3Brush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LineBrightBrush}"/>
|
||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource TextBrush}"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.nav">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextDimBrush}"/>
|
||||
<Setter Property="Padding" Value="6,2"/>
|
||||
<Setter Property="CornerRadius" Value="6"/>
|
||||
<Setter Property="MinWidth" Value="28"/>
|
||||
</Style>
|
||||
<Style Selector="Button.nav:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource Surface3Brush}"/>
|
||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource TextBrush}"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.day">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderBrush" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||
<Setter Property="Width" Value="32"/>
|
||||
<Setter Property="Height" Value="32"/>
|
||||
<Setter Property="CornerRadius" Value="999"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
</Style>
|
||||
<Style Selector="Button.day:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource Surface3Brush}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.day.outside">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFaintBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.day.today">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.day.selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
|
||||
<Setter Property="TextElement.Foreground" Value="White"/>
|
||||
</Style>
|
||||
<Style Selector="Button.day.selected:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentDimBrush}"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.weekday">
|
||||
<Setter Property="HorizontalAlignment" Value="Center"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextMuteBrush}"/>
|
||||
<Setter Property="FontSize" Value="10"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid>
|
||||
<ToggleButton x:Name="TriggerButton" Classes="trigger">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<PathIcon Grid.Column="0" Width="14" Height="14"
|
||||
Margin="0,0,8,0"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
Data="M19,4H18V2H16V4H8V2H6V4H5A2,2 0 0,0 3,6V20A2,2 0 0,0 5,22H19A2,2 0 0,0 21,20V6A2,2 0 0,0 19,4M19,20H5V10H19V20M19,8H5V6H19V8Z"/>
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding #Root.DisplayText}"
|
||||
Foreground="{Binding #Root.DisplayForeground}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<PathIcon Grid.Column="2" Width="10" Height="10"
|
||||
Margin="8,0,0,0"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
Data="M7,10L12,15L17,10H7Z"/>
|
||||
</Grid>
|
||||
</ToggleButton>
|
||||
|
||||
<Popup x:Name="PickerPopup"
|
||||
PlacementTarget="{Binding #TriggerButton}"
|
||||
Placement="Bottom"
|
||||
IsOpen="{Binding #TriggerButton.IsChecked, Mode=TwoWay}"
|
||||
IsLightDismissEnabled="True">
|
||||
<Border Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="10"
|
||||
Padding="14"
|
||||
Margin="0,4,0,0"
|
||||
BoxShadow="{StaticResource ModalShadow}"
|
||||
MinWidth="300">
|
||||
<StackPanel Spacing="10">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="quick" Content="Today" Click="OnTodayClick"/>
|
||||
<Button Classes="quick" Content="Tomorrow" Click="OnTomorrowClick"/>
|
||||
<Button Classes="quick" Content="Next Mon" Click="OnNextMondayClick"/>
|
||||
<Button Classes="quick" Content="Clear" Click="OnClearClick"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,2,0,0">
|
||||
<Button Grid.Column="0" Click="OnPrevMonthClick" Classes="nav" Content="◀"/>
|
||||
<TextBlock Grid.Column="1" x:Name="MonthHeader"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource TextBrush}"/>
|
||||
<Button Grid.Column="2" Click="OnNextMonthClick" Classes="nav" Content="▶"/>
|
||||
</Grid>
|
||||
|
||||
<UniformGrid Columns="7" x:Name="WeekdayHeaders"/>
|
||||
|
||||
<UniformGrid Columns="7" Rows="6" x:Name="DayGrid"/>
|
||||
|
||||
<Grid x:Name="TimeRow"
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
Margin="0,4,0,0">
|
||||
<TextBlock Grid.Column="0" Text="Time"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
Margin="0,0,8,0"/>
|
||||
<TextBox Grid.Column="1" x:Name="TimeInput"
|
||||
Watermark="HH:mm" MaxLength="5"
|
||||
Text="{Binding #Root.TimeText, Mode=TwoWay}"/>
|
||||
<Button Grid.Column="2" Content="Done"
|
||||
Click="OnDoneClick"
|
||||
Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
423
src/ClaudeDo.Ui/Views/Controls/ThemedDatePicker.axaml.cs
Normal file
423
src/ClaudeDo.Ui/Views/Controls/ThemedDatePicker.axaml.cs
Normal file
@@ -0,0 +1,423 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Controls;
|
||||
|
||||
public partial class ThemedDatePicker : UserControl
|
||||
{
|
||||
public static readonly StyledProperty<DateTime?> SelectedDateProperty =
|
||||
AvaloniaProperty.Register<ThemedDatePicker, DateTime?>(
|
||||
nameof(SelectedDate), defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public static readonly StyledProperty<bool> ShowTimeProperty =
|
||||
AvaloniaProperty.Register<ThemedDatePicker, bool>(nameof(ShowTime), false);
|
||||
|
||||
public static readonly StyledProperty<bool> IsRangeProperty =
|
||||
AvaloniaProperty.Register<ThemedDatePicker, bool>(nameof(IsRange), false);
|
||||
|
||||
public static readonly StyledProperty<DateTime?> StartDateProperty =
|
||||
AvaloniaProperty.Register<ThemedDatePicker, DateTime?>(
|
||||
nameof(StartDate), defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public static readonly StyledProperty<DateTime?> EndDateProperty =
|
||||
AvaloniaProperty.Register<ThemedDatePicker, DateTime?>(
|
||||
nameof(EndDate), defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public static readonly StyledProperty<string?> WatermarkProperty =
|
||||
AvaloniaProperty.Register<ThemedDatePicker, string?>(nameof(Watermark), "Pick a date");
|
||||
|
||||
public static readonly StyledProperty<string?> DisplayTextProperty =
|
||||
AvaloniaProperty.Register<ThemedDatePicker, string?>(nameof(DisplayText));
|
||||
|
||||
public static readonly StyledProperty<IBrush?> DisplayForegroundProperty =
|
||||
AvaloniaProperty.Register<ThemedDatePicker, IBrush?>(nameof(DisplayForeground));
|
||||
|
||||
public static readonly StyledProperty<string?> TimeTextProperty =
|
||||
AvaloniaProperty.Register<ThemedDatePicker, string?>(
|
||||
nameof(TimeText), "09:00",
|
||||
defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public DateTime? SelectedDate
|
||||
{
|
||||
get => GetValue(SelectedDateProperty);
|
||||
set => SetValue(SelectedDateProperty, value);
|
||||
}
|
||||
|
||||
public bool ShowTime
|
||||
{
|
||||
get => GetValue(ShowTimeProperty);
|
||||
set => SetValue(ShowTimeProperty, value);
|
||||
}
|
||||
|
||||
public bool IsRange
|
||||
{
|
||||
get => GetValue(IsRangeProperty);
|
||||
set => SetValue(IsRangeProperty, value);
|
||||
}
|
||||
|
||||
public DateTime? StartDate
|
||||
{
|
||||
get => GetValue(StartDateProperty);
|
||||
set => SetValue(StartDateProperty, value);
|
||||
}
|
||||
|
||||
public DateTime? EndDate
|
||||
{
|
||||
get => GetValue(EndDateProperty);
|
||||
set => SetValue(EndDateProperty, value);
|
||||
}
|
||||
|
||||
public string? Watermark
|
||||
{
|
||||
get => GetValue(WatermarkProperty);
|
||||
set => SetValue(WatermarkProperty, value);
|
||||
}
|
||||
|
||||
public string? DisplayText
|
||||
{
|
||||
get => GetValue(DisplayTextProperty);
|
||||
set => SetValue(DisplayTextProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? DisplayForeground
|
||||
{
|
||||
get => GetValue(DisplayForegroundProperty);
|
||||
set => SetValue(DisplayForegroundProperty, value);
|
||||
}
|
||||
|
||||
public string? TimeText
|
||||
{
|
||||
get => GetValue(TimeTextProperty);
|
||||
set => SetValue(TimeTextProperty, value);
|
||||
}
|
||||
|
||||
private static readonly string[] TimeFormats = { @"h\:mm", @"hh\:mm" };
|
||||
|
||||
private DateTime _displayMonth;
|
||||
private bool _suppressTimeSync;
|
||||
|
||||
public ThemedDatePicker()
|
||||
{
|
||||
InitializeComponent();
|
||||
_displayMonth = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||
BuildWeekdayHeaders();
|
||||
BuildDayGrid();
|
||||
UpdateDisplayText();
|
||||
UpdateTimeRowVisibility();
|
||||
|
||||
PickerPopup.Opened += OnPopupOpened;
|
||||
}
|
||||
|
||||
private void UpdateTimeRowVisibility()
|
||||
{
|
||||
if (TimeRow is null) return;
|
||||
TimeRow.IsVisible = ShowTime && !IsRange;
|
||||
}
|
||||
|
||||
private void OnPopupOpened(object? sender, EventArgs e)
|
||||
{
|
||||
var seed = AnchorDate() ?? DateTime.Today;
|
||||
_displayMonth = new DateTime(seed.Year, seed.Month, 1);
|
||||
BuildDayGrid();
|
||||
}
|
||||
|
||||
private DateTime? AnchorDate() =>
|
||||
IsRange ? (StartDate ?? EndDate) : SelectedDate;
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property == SelectedDateProperty)
|
||||
{
|
||||
UpdateDisplayText();
|
||||
SyncTimeFromSelected();
|
||||
BuildDayGrid();
|
||||
}
|
||||
else if (change.Property == StartDateProperty || change.Property == EndDateProperty)
|
||||
{
|
||||
UpdateDisplayText();
|
||||
BuildDayGrid();
|
||||
}
|
||||
else if (change.Property == ShowTimeProperty || change.Property == WatermarkProperty
|
||||
|| change.Property == IsRangeProperty)
|
||||
{
|
||||
UpdateDisplayText();
|
||||
BuildDayGrid();
|
||||
UpdateTimeRowVisibility();
|
||||
}
|
||||
else if (change.Property == TimeTextProperty)
|
||||
{
|
||||
ApplyTimeTextToSelected();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDisplayText()
|
||||
{
|
||||
if (IsRange)
|
||||
{
|
||||
var (s, end) = NormalizeRange(StartDate?.Date, EndDate?.Date);
|
||||
if (s is null && end is null)
|
||||
{
|
||||
DisplayText = Watermark ?? "Pick a range";
|
||||
DisplayForeground = TryGetBrush("TextDimBrush");
|
||||
return;
|
||||
}
|
||||
if (s is not null && end is null)
|
||||
{
|
||||
DisplayText = $"{s.Value:MMM d} – select end";
|
||||
DisplayForeground = TryGetBrush("TextBrush");
|
||||
return;
|
||||
}
|
||||
// both set
|
||||
var sd = s!.Value;
|
||||
var ed = end!.Value;
|
||||
DisplayText = sd.Year == ed.Year
|
||||
? $"{sd:MMM d} – {ed:MMM d, yyyy}"
|
||||
: $"{sd:MMM d, yyyy} – {ed:MMM d, yyyy}";
|
||||
DisplayForeground = TryGetBrush("TextBrush");
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedDate is null)
|
||||
{
|
||||
DisplayText = Watermark ?? "Pick a date";
|
||||
DisplayForeground = TryGetBrush("TextDimBrush");
|
||||
return;
|
||||
}
|
||||
var d = SelectedDate.Value;
|
||||
DisplayText = ShowTime
|
||||
? d.ToString("MMM d, yyyy · HH:mm", CultureInfo.CurrentCulture)
|
||||
: d.ToString("MMM d, yyyy", CultureInfo.CurrentCulture);
|
||||
DisplayForeground = TryGetBrush("TextBrush");
|
||||
}
|
||||
|
||||
private static (DateTime? Start, DateTime? End) NormalizeRange(DateTime? a, DateTime? b)
|
||||
{
|
||||
if (a is null && b is null) return (null, null);
|
||||
if (a is null) return (b, b);
|
||||
if (b is null) return (a, null);
|
||||
return a.Value <= b.Value ? (a, b) : (b, a);
|
||||
}
|
||||
|
||||
private IBrush? TryGetBrush(string key)
|
||||
{
|
||||
if (this.TryFindResource(key, out var v) && v is IBrush b) return b;
|
||||
return null;
|
||||
}
|
||||
|
||||
private void SyncTimeFromSelected()
|
||||
{
|
||||
if (SelectedDate is null) return;
|
||||
_suppressTimeSync = true;
|
||||
TimeText = SelectedDate.Value.ToString("HH:mm");
|
||||
_suppressTimeSync = false;
|
||||
}
|
||||
|
||||
private void ApplyTimeTextToSelected()
|
||||
{
|
||||
if (_suppressTimeSync || !ShowTime || IsRange || SelectedDate is null) return;
|
||||
if (TryParseTime(TimeText, out var t))
|
||||
{
|
||||
var d = SelectedDate.Value.Date + t;
|
||||
if (d != SelectedDate) SelectedDate = d;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseTime(string? text, out TimeSpan ts)
|
||||
{
|
||||
ts = default;
|
||||
if (string.IsNullOrWhiteSpace(text)) return false;
|
||||
return TimeSpan.TryParseExact(text, TimeFormats, CultureInfo.InvariantCulture, out ts);
|
||||
}
|
||||
|
||||
private void BuildWeekdayHeaders()
|
||||
{
|
||||
if (WeekdayHeaders is null) return;
|
||||
WeekdayHeaders.Children.Clear();
|
||||
var firstDow = CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek;
|
||||
var names = CultureInfo.CurrentCulture.DateTimeFormat.AbbreviatedDayNames;
|
||||
for (int i = 0; i < 7; i++)
|
||||
{
|
||||
var dow = (DayOfWeek)(((int)firstDow + i) % 7);
|
||||
var name = names[(int)dow];
|
||||
if (name.Length > 3) name = name.Substring(0, 3);
|
||||
WeekdayHeaders.Children.Add(new TextBlock { Text = name, Classes = { "weekday" } });
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildDayGrid()
|
||||
{
|
||||
if (DayGrid is null || MonthHeader is null) return;
|
||||
DayGrid.Children.Clear();
|
||||
MonthHeader.Text = _displayMonth.ToString("MMMM yyyy", CultureInfo.CurrentCulture);
|
||||
|
||||
var firstDow = CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek;
|
||||
var offset = ((int)_displayMonth.DayOfWeek - (int)firstDow + 7) % 7;
|
||||
var start = _displayMonth.AddDays(-offset);
|
||||
|
||||
var today = DateTime.Today;
|
||||
var sel = SelectedDate?.Date;
|
||||
var (rs, re) = NormalizeRange(StartDate?.Date, EndDate?.Date);
|
||||
var rangeFill = TryGetBrush("AccentSoftBrush");
|
||||
|
||||
for (int i = 0; i < 42; i++)
|
||||
{
|
||||
var day = start.AddDays(i);
|
||||
var cell = new Grid();
|
||||
|
||||
if (IsRange && rs.HasValue && re.HasValue && rs.Value != re.Value
|
||||
&& day >= rs.Value && day <= re.Value)
|
||||
{
|
||||
Thickness margin;
|
||||
if (day == rs.Value)
|
||||
margin = new Thickness(19, 0, 0, 0);
|
||||
else if (day == re.Value)
|
||||
margin = new Thickness(0, 0, 19, 0);
|
||||
else
|
||||
margin = default;
|
||||
|
||||
cell.Children.Add(new Border
|
||||
{
|
||||
Background = rangeFill,
|
||||
Margin = margin,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch
|
||||
});
|
||||
}
|
||||
|
||||
var btn = new Button
|
||||
{
|
||||
Content = day.Day.ToString(CultureInfo.CurrentCulture),
|
||||
Classes = { "day" },
|
||||
Tag = day
|
||||
};
|
||||
if (day.Month != _displayMonth.Month) btn.Classes.Add("outside");
|
||||
if (day == today) btn.Classes.Add("today");
|
||||
|
||||
var isSelected = IsRange
|
||||
? (rs.HasValue && day == rs.Value) || (re.HasValue && day == re.Value)
|
||||
: sel.HasValue && day == sel.Value;
|
||||
if (isSelected) btn.Classes.Add("selected");
|
||||
|
||||
btn.Click += OnDayClick;
|
||||
cell.Children.Add(btn);
|
||||
DayGrid.Children.Add(cell);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDayClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button { Tag: DateTime day }) return;
|
||||
|
||||
if (IsRange)
|
||||
{
|
||||
// State A: nothing or both set → start a fresh range
|
||||
// State B: only start set → complete (or restart) range
|
||||
if (StartDate is null || EndDate is not null)
|
||||
{
|
||||
StartDate = day.Date;
|
||||
EndDate = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var s = StartDate.Value.Date;
|
||||
if (day.Date < s)
|
||||
{
|
||||
StartDate = day.Date;
|
||||
EndDate = s;
|
||||
}
|
||||
else
|
||||
{
|
||||
EndDate = day.Date;
|
||||
}
|
||||
}
|
||||
BuildDayGrid();
|
||||
return;
|
||||
}
|
||||
|
||||
var time = TimeSpan.Zero;
|
||||
if (ShowTime)
|
||||
{
|
||||
if (TryParseTime(TimeText, out var parsed)) time = parsed;
|
||||
else if (SelectedDate is { } cur) time = cur.TimeOfDay;
|
||||
}
|
||||
else if (SelectedDate is { } cur) time = cur.TimeOfDay;
|
||||
|
||||
SelectedDate = day.Date + time;
|
||||
if (!ShowTime) PickerPopup.IsOpen = false;
|
||||
}
|
||||
|
||||
private void OnPrevMonthClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_displayMonth = _displayMonth.AddMonths(-1);
|
||||
BuildDayGrid();
|
||||
}
|
||||
|
||||
private void OnNextMonthClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_displayMonth = _displayMonth.AddMonths(1);
|
||||
BuildDayGrid();
|
||||
}
|
||||
|
||||
private void OnTodayClick(object? sender, RoutedEventArgs e) => SetQuickDate(DateTime.Today);
|
||||
|
||||
private void OnTomorrowClick(object? sender, RoutedEventArgs e) => SetQuickDate(DateTime.Today.AddDays(1));
|
||||
|
||||
private void OnNextMondayClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
int delta = ((int)DayOfWeek.Monday - (int)today.DayOfWeek + 7) % 7;
|
||||
if (delta == 0) delta = 7;
|
||||
SetQuickDate(today.AddDays(delta));
|
||||
}
|
||||
|
||||
private void OnClearClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (IsRange)
|
||||
{
|
||||
StartDate = null;
|
||||
EndDate = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedDate = null;
|
||||
}
|
||||
PickerPopup.IsOpen = false;
|
||||
}
|
||||
|
||||
private void SetQuickDate(DateTime date)
|
||||
{
|
||||
_displayMonth = new DateTime(date.Year, date.Month, 1);
|
||||
|
||||
if (IsRange)
|
||||
{
|
||||
StartDate = date.Date;
|
||||
EndDate = date.Date;
|
||||
BuildDayGrid();
|
||||
PickerPopup.IsOpen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var time = TimeSpan.Zero;
|
||||
if (ShowTime)
|
||||
{
|
||||
if (!TryParseTime(TimeText, out time))
|
||||
time = SelectedDate?.TimeOfDay ?? new TimeSpan(9, 0, 0);
|
||||
}
|
||||
SelectedDate = date.Date + time;
|
||||
BuildDayGrid();
|
||||
if (!ShowTime) PickerPopup.IsOpen = false;
|
||||
}
|
||||
|
||||
private void OnDoneClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
ApplyTimeTextToSelected();
|
||||
PickerPopup.IsOpen = false;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
<!-- ── Header (sticky top): eyebrow · title · gear (agent-settings flyout) ── -->
|
||||
<Border DockPanel.Dock="Top" Classes="island-header">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,4">
|
||||
<Ellipse Width="5" Height="5" Fill="{DynamicResource AccentBrush}"
|
||||
@@ -56,7 +56,15 @@
|
||||
Padding="0"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1" Classes="icon-btn"
|
||||
<ComboBox Grid.Column="1"
|
||||
ItemsSource="{Binding StatusOptions}"
|
||||
SelectedItem="{Binding SelectedStatus, Mode=TwoWay}"
|
||||
ToolTip.Tip="Set status (no transition guards)"
|
||||
VerticalAlignment="Top"
|
||||
MinWidth="110"
|
||||
Margin="6,0,0,0"/>
|
||||
|
||||
<Button Grid.Column="2" Classes="icon-btn"
|
||||
ToolTip.Tip="Agent settings"
|
||||
IsEnabled="{Binding IsAgentSectionEnabled}"
|
||||
VerticalAlignment="Top"
|
||||
@@ -139,6 +147,46 @@
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="0">
|
||||
|
||||
<!-- Tags section -->
|
||||
<Border Padding="18,12,18,12"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="TAGS" Margin="0,0,0,2"/>
|
||||
<ItemsControl ItemsSource="{Binding Tags}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<Border Classes="chip chip-tag" Margin="0,0,6,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding}" VerticalAlignment="Center"/>
|
||||
<Button Classes="icon-btn"
|
||||
Padding="2,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="Remove tag"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).RemoveTagCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Text="×" FontSize="12"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<AutoCompleteBox ItemsSource="{Binding AvailableTags}"
|
||||
Text="{Binding NewTagInput, Mode=TwoWay}"
|
||||
Watermark="Add tag (Enter to add)">
|
||||
<AutoCompleteBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddTagCommand}"/>
|
||||
</AutoCompleteBox.KeyBindings>
|
||||
</AutoCompleteBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Planning merge section — visible only for planning parent tasks -->
|
||||
<Border Padding="18,12,18,12"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
x:Class="ClaudeDo.Ui.Views.Islands.TaskRowView"
|
||||
x:DataType="vm:TaskRowViewModel">
|
||||
<Grid>
|
||||
@@ -30,14 +31,24 @@
|
||||
Classes.selected="{Binding IsSelected}"
|
||||
Classes.done="{Binding Done}">
|
||||
<Border.ContextMenu>
|
||||
<ContextMenu>
|
||||
<ContextMenu Opening="OnContextMenuOpening">
|
||||
<MenuItem Header="Send to queue"
|
||||
IsVisible="{Binding !IsQueued}"
|
||||
Click="OnSendToQueueClick"/>
|
||||
<MenuItem Header="Remove from queue"
|
||||
IsVisible="{Binding IsQueued}"
|
||||
IsVisible="{Binding CanRemoveFromQueue}"
|
||||
Click="OnRemoveFromQueueClick"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Set status">
|
||||
<MenuItem Header="Idle" Tag="Idle" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Queued" Tag="Queued" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Running" Tag="Running" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Done" Tag="Done" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Failed" Tag="Failed" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="Cancelled" Tag="Cancelled" Click="OnSetStatusClick"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Tags" x:Name="TagsMenu"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Run interactively"
|
||||
Click="OnRunInteractivelyClick"/>
|
||||
<MenuItem Header="Open planning Session"
|
||||
@@ -119,9 +130,9 @@
|
||||
<TextBlock Text="{Binding Status}"/>
|
||||
</Border>
|
||||
|
||||
<!-- Dequeue button (only when Queued) -->
|
||||
<!-- Dequeue button (visible when row is Queued, or planning parent has queued subtasks) -->
|
||||
<Button Classes="icon-btn dequeue-btn"
|
||||
IsVisible="{Binding IsQueued}"
|
||||
IsVisible="{Binding CanRemoveFromQueue}"
|
||||
ToolTip.Tip="Remove from queue"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
@@ -224,15 +235,9 @@
|
||||
Foreground="{DynamicResource TextBrush}"/>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="DATE" FontSize="10" Opacity="0.6"
|
||||
<TextBlock Text="WHEN" FontSize="10" Opacity="0.6"
|
||||
Foreground="{DynamicResource TextDimBrush}"/>
|
||||
<DatePicker x:Name="ScheduleDate" HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="TIME" FontSize="10" Opacity="0.6"
|
||||
Foreground="{DynamicResource TextDimBrush}"/>
|
||||
<TimePicker x:Name="ScheduleTime" ClockIdentifier="24HourClock"
|
||||
<ctl:ThemedDatePicker x:Name="ScheduleDate" ShowTime="True"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -3,11 +3,14 @@ using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.VisualTree;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands;
|
||||
|
||||
@@ -69,13 +72,52 @@ public partial class TaskRowView : UserControl
|
||||
await vm.QueuePlanningSubtasksCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnSetStatusClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not MenuItem mi) return;
|
||||
if (DataContext is not TaskRowViewModel row) return;
|
||||
if (FindTasksVm() is not { } vm) return;
|
||||
if (mi.Tag is not string tag) return;
|
||||
if (!System.Enum.TryParse<TaskStatus>(tag, ignoreCase: true, out var status)) return;
|
||||
await vm.SetStatusOnRowAsync(row, status);
|
||||
}
|
||||
|
||||
private void OnContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
if (DataContext is not TaskRowViewModel row || FindTasksVm() is not { } vm) return;
|
||||
|
||||
// Build the union of all known tags + tags currently on this row, so a row's
|
||||
// own tags stay reachable from the menu even if the global list is stale.
|
||||
var rowTags = row.Tags.ToHashSet();
|
||||
var union = vm.AllTags.Concat(rowTags).Distinct().OrderBy(t => t).ToList();
|
||||
|
||||
TagsMenu.Items.Clear();
|
||||
if (union.Count == 0)
|
||||
{
|
||||
TagsMenu.Items.Add(new MenuItem { Header = "(no tags yet)", IsEnabled = false });
|
||||
return;
|
||||
}
|
||||
foreach (var name in union)
|
||||
{
|
||||
var prefix = rowTags.Contains(name) ? "✓ " : " ";
|
||||
var item = new MenuItem { Header = prefix + name, Tag = name };
|
||||
item.Click += OnToggleTagClick;
|
||||
TagsMenu.Items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnToggleTagClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not MenuItem mi || mi.Tag is not string name) return;
|
||||
if (DataContext is not TaskRowViewModel row || FindTasksVm() is not { } vm) return;
|
||||
await vm.ToggleTagOnRowAsync(row, name);
|
||||
}
|
||||
|
||||
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not TaskRowViewModel row) return;
|
||||
_pendingScheduleRow = row;
|
||||
var seed = row.ScheduledFor ?? DateTime.Now.AddHours(1);
|
||||
ScheduleDate.SelectedDate = new DateTimeOffset(seed.Date, TimeSpan.Zero);
|
||||
ScheduleTime.SelectedTime = seed.TimeOfDay;
|
||||
ScheduleDate.SelectedDate = row.ScheduledFor ?? DateTime.Now.AddHours(1);
|
||||
ScheduleAnchor.Flyout?.ShowAt(ScheduleAnchor);
|
||||
}
|
||||
|
||||
@@ -83,9 +125,7 @@ public partial class TaskRowView : UserControl
|
||||
{
|
||||
ScheduleAnchor.Flyout?.Hide();
|
||||
if (_pendingScheduleRow is null || ScheduleDate.SelectedDate is null) return;
|
||||
var date = ScheduleDate.SelectedDate.Value.Date;
|
||||
var time = ScheduleTime.SelectedTime ?? TimeSpan.FromHours(9);
|
||||
var when = date + time;
|
||||
var when = ScheduleDate.SelectedDate.Value;
|
||||
if (FindTasksVm() is { } tvm)
|
||||
await tvm.SetScheduledForAsync(_pendingScheduleRow, when);
|
||||
_pendingScheduleRow = null;
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
Foreground="{DynamicResource TextDimBrush}">
|
||||
<MenuItem Header="Check for updates"
|
||||
Command="{Binding CheckForUpdatesCommand}"/>
|
||||
<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</StackPanel>
|
||||
@@ -216,6 +217,15 @@
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<!-- Right: prime status notification -->
|
||||
<TextBlock DockPanel.Dock="Right"
|
||||
Text="{Binding PrimeStatus}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
FontSize="11"
|
||||
VerticalAlignment="Center"
|
||||
Margin="12,0,0,0"
|
||||
IsVisible="{Binding PrimeStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
|
||||
<!-- Spacer between pill and log -->
|
||||
<Panel/>
|
||||
</DockPanel>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.Views.Modals;
|
||||
using ClaudeDo.Ui.Views.Planning;
|
||||
|
||||
namespace ClaudeDo.Ui.Views;
|
||||
@@ -23,6 +24,13 @@ public partial class MainWindow : Window
|
||||
var modal = new ConflictResolutionView { DataContext = conflictVm };
|
||||
await modal.ShowDialog(this);
|
||||
};
|
||||
vm.ShowAboutModal = async (aboutVm) =>
|
||||
{
|
||||
var dlg = new AboutModalView { DataContext = aboutVm };
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
aboutVm.CloseAction = () => { dlg.Close(); tcs.TrySetResult(true); };
|
||||
await dlg.ShowDialog(this);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
49
src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml
Normal file
49
src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml
Normal file
@@ -0,0 +1,49 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||
x:Class="ClaudeDo.Ui.Views.Modals.AboutModalView"
|
||||
x:DataType="vm:AboutModalViewModel"
|
||||
Title="About ClaudeDo"
|
||||
Width="480" Height="280"
|
||||
SystemDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1">
|
||||
<Grid RowDefinitions="36,*,52">
|
||||
<Border Grid.Row="0" Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
|
||||
<TextBlock Text="ABOUT" FontFamily="{DynamicResource MonoFont}" FontSize="11"
|
||||
LetterSpacing="1.4" Foreground="{DynamicResource TextBrush}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Classes="icon-btn" Content="✕" FontSize="12"
|
||||
Command="{Binding CloseCommand}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ScrollViewer Grid.Row="1" Padding="20,16">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="10">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Version" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding AppVersion}" FontFamily="{DynamicResource MonoFont}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Data" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding DataFolderPath}" FontFamily="{DynamicResource MonoFont}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||
<Button Grid.Row="1" Grid.Column="2" Content="Open" Command="{Binding OpenPathCommand}" CommandParameter="{Binding DataFolderPath}"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="Logs" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding LogsFolderPath}" FontFamily="{DynamicResource MonoFont}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||
<Button Grid.Row="2" Grid.Column="2" Content="Open" Command="{Binding OpenPathCommand}" CommandParameter="{Binding LogsFolderPath}"/>
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Config" Foreground="{DynamicResource TextDimBrush}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding WorkerConfigPath}" FontFamily="{DynamicResource MonoFont}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||
<Button Grid.Row="3" Grid.Column="2" Content="Open" Command="{Binding OpenPathCommand}" CommandParameter="{Binding WorkerConfigPath}"/>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
<Border Grid.Row="2" Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="16,0">
|
||||
<Button Content="Close" Command="{Binding CloseCommand}" MinWidth="90"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
10
src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml.cs
Normal file
10
src/ClaudeDo.Ui/Views/Modals/AboutModalView.axaml.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Modals;
|
||||
|
||||
public partial class AboutModalView : Window
|
||||
{
|
||||
public AboutModalView() => InitializeComponent();
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||
xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings"
|
||||
xmlns:conv="using:ClaudeDo.Ui.Converters"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
x:Class="ClaudeDo.Ui.Views.Modals.SettingsModalView"
|
||||
x:DataType="vm:SettingsModalViewModel"
|
||||
Title="Settings"
|
||||
@@ -14,6 +17,11 @@
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<Window.Resources>
|
||||
<conv:DateOnlyToDateTimeConverter x:Key="DateOnlyToDateTime"/>
|
||||
<conv:TimeSpanToHhmmConverter x:Key="TimeSpanToHhmm"/>
|
||||
</Window.Resources>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="TextBlock.section-label">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
|
||||
@@ -33,13 +41,6 @@
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextDimBrush}"/>
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
||||
</Style>
|
||||
<Style Selector="Border.section">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="6"/>
|
||||
<Setter Property="Padding" Value="14"/>
|
||||
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.danger">
|
||||
<Setter Property="Background" Value="{DynamicResource BloodBrush}"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
@@ -57,8 +58,7 @@
|
||||
<Grid RowDefinitions="36,*,52">
|
||||
|
||||
<!-- Title bar -->
|
||||
<Border Grid.Row="0"
|
||||
x:Name="TitleBar"
|
||||
<Border Grid.Row="0" x:Name="TitleBar"
|
||||
Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
@@ -70,217 +70,193 @@
|
||||
LetterSpacing="1.4"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1"
|
||||
Classes="icon-btn"
|
||||
Content="✕"
|
||||
FontSize="12"
|
||||
Command="{Binding CancelCommand}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Classes="icon-btn" Content="✕" FontSize="12"
|
||||
Command="{Binding CancelCommand}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<ScrollViewer Grid.Row="1" Padding="20,16">
|
||||
<StackPanel Spacing="18">
|
||||
|
||||
<!-- CLAUDE DEFAULTS -->
|
||||
<StackPanel Spacing="0">
|
||||
<TextBlock Classes="section-label" Text="CLAUDE DEFAULTS"/>
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="12">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Default instructions"/>
|
||||
<TextBox AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
Height="110"
|
||||
Watermark="Baseline instructions applied to every task (e.g. 'speak German', 'never touch .env')"
|
||||
Text="{Binding DefaultClaudeInstructions, Mode=TwoWay}"/>
|
||||
<!-- Body: tabs + bottom validation/status strip -->
|
||||
<DockPanel Grid.Row="1">
|
||||
<StackPanel DockPanel.Dock="Bottom" Margin="20,0,20,8" Spacing="2">
|
||||
<TextBlock Text="{Binding ValidationError}"
|
||||
Foreground="{DynamicResource BloodBrush}" FontSize="11"
|
||||
IsVisible="{Binding ValidationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
Foreground="{DynamicResource TextDimBrush}" FontSize="11"
|
||||
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<TabControl Padding="20,12" TabStripPlacement="Top">
|
||||
|
||||
<TabItem Header="General">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Default instructions"/>
|
||||
<TextBox AcceptsReturn="True" TextWrapping="Wrap" Height="110"
|
||||
Watermark="Baseline instructions applied to every task"
|
||||
Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,12,*,12,*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Model"/>
|
||||
<ComboBox ItemsSource="{Binding Models}"
|
||||
SelectedItem="{Binding DefaultModel, Mode=TwoWay}"
|
||||
<ComboBox ItemsSource="{Binding General.Models}"
|
||||
SelectedItem="{Binding General.DefaultModel, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Max turns"/>
|
||||
<NumericUpDown Value="{Binding DefaultMaxTurns, Mode=TwoWay}"
|
||||
<NumericUpDown Value="{Binding General.DefaultMaxTurns, Mode=TwoWay}"
|
||||
Minimum="1" Maximum="200" Increment="1" FormatString="0"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="4" Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Permission"/>
|
||||
<ComboBox ItemsSource="{Binding PermissionModes}"
|
||||
SelectedItem="{Binding DefaultPermissionMode, Mode=TwoWay}"
|
||||
<ComboBox ItemsSource="{Binding General.PermissionModes}"
|
||||
SelectedItem="{Binding General.DefaultPermissionMode, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<!-- WORKTREES -->
|
||||
<StackPanel Spacing="0">
|
||||
<TextBlock Classes="section-label" Text="WORKTREES"/>
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="12">
|
||||
<TabItem Header="Worktrees">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||||
<Grid ColumnDefinitions="*,12,2*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Strategy"/>
|
||||
<ComboBox ItemsSource="{Binding WorktreeStrategies}"
|
||||
SelectedItem="{Binding WorktreeStrategy, Mode=TwoWay}"
|
||||
<ComboBox ItemsSource="{Binding Worktrees.WorktreeStrategies}"
|
||||
SelectedItem="{Binding Worktrees.WorktreeStrategy, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Central worktree root"/>
|
||||
<TextBox Text="{Binding CentralWorktreeRoot, Mode=TwoWay}"
|
||||
<TextBox Text="{Binding Worktrees.CentralWorktreeRoot, Mode=TwoWay}"
|
||||
Watermark="e.g. C:\worktrees"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<CheckBox IsChecked="{Binding WorktreeAutoCleanupEnabled, Mode=TwoWay}"
|
||||
<CheckBox IsChecked="{Binding Worktrees.WorktreeAutoCleanupEnabled, Mode=TwoWay}"
|
||||
Content="Auto-cleanup finished worktrees after"
|
||||
VerticalAlignment="Center"/>
|
||||
<NumericUpDown Value="{Binding WorktreeAutoCleanupDays, Mode=TwoWay}"
|
||||
<NumericUpDown Value="{Binding Worktrees.WorktreeAutoCleanupDays, Mode=TwoWay}"
|
||||
Width="130" Minimum="1" Maximum="365" Increment="1" FormatString="0"
|
||||
IsEnabled="{Binding WorktreeAutoCleanupEnabled}"/>
|
||||
<TextBlock Text="days" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextDimBrush}"/>
|
||||
IsEnabled="{Binding Worktrees.WorktreeAutoCleanupEnabled}"/>
|
||||
<TextBlock Text="days" VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,4,0,0"/>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<Button Content="Cleanup finished worktrees"
|
||||
Command="{Binding CleanupWorktreesCommand}"
|
||||
Command="{Binding Worktrees.CleanupWorktreesCommand}"
|
||||
HorizontalAlignment="Left"/>
|
||||
|
||||
<!-- Force-remove: button vs. confirm bar -->
|
||||
<StackPanel>
|
||||
<Button Content="Force-remove all worktrees"
|
||||
Classes="danger"
|
||||
Command="{Binding RequestResetConfirmCommand}"
|
||||
<Button Content="Force-remove all worktrees" Classes="danger"
|
||||
Command="{Binding Worktrees.RequestResetConfirmCommand}"
|
||||
HorizontalAlignment="Left"
|
||||
IsVisible="{Binding !ShowResetConfirm}"/>
|
||||
|
||||
IsVisible="{Binding !Worktrees.ShowResetConfirm}"/>
|
||||
<Border BorderBrush="{DynamicResource BloodBrush}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="12,10"
|
||||
IsVisible="{Binding ShowResetConfirm}">
|
||||
IsVisible="{Binding Worktrees.ShowResetConfirm}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Remove ALL worktrees? Uncommitted work will be lost."
|
||||
Foreground="{DynamicResource TextBrush}" TextWrapping="Wrap"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Cancel" Command="{Binding CancelResetConfirmCommand}"/>
|
||||
<Button Content="Cancel" Command="{Binding Worktrees.CancelResetConfirmCommand}"/>
|
||||
<Button Content="Remove All" Classes="danger"
|
||||
Command="{Binding ConfirmResetAllCommand}"/>
|
||||
Command="{Binding Worktrees.ConfirmResetAllCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- AGENTS -->
|
||||
<StackPanel Spacing="0">
|
||||
<TextBlock Classes="section-label" Text="AGENTS"/>
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Restore bundled default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher). Existing files are not overwritten."
|
||||
FontSize="11"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextDimBrush}"/>
|
||||
<Button Content="Restore default agents"
|
||||
Command="{Binding RestoreDefaultAgentsCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- PROMPTS -->
|
||||
<StackPanel Spacing="0">
|
||||
<TextBlock Classes="section-label" Text="PROMPTS"/>
|
||||
<Border Classes="section">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="8">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="System"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono"
|
||||
Text="{Binding SystemPromptPath}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Row="0" Grid.Column="2" Content="Open in editor"
|
||||
Command="{Binding OpenPromptCommand}"
|
||||
CommandParameter="System"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Classes="field-label" Text="Planning"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Classes="path-mono"
|
||||
Text="{Binding PlanningPromptPath}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Row="1" Grid.Column="2" Content="Open in editor"
|
||||
Command="{Binding OpenPromptCommand}"
|
||||
CommandParameter="Planning"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Classes="field-label" Text="Agent"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" Classes="path-mono"
|
||||
Text="{Binding AgentPromptPath}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Row="2" Grid.Column="2" Content="Open in editor"
|
||||
Command="{Binding OpenPromptCommand}"
|
||||
CommandParameter="Agent"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ABOUT -->
|
||||
<StackPanel Spacing="0">
|
||||
<TextBlock Classes="section-label" Text="ABOUT"/>
|
||||
<Border Classes="section">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="8">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="Version"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono"
|
||||
Text="{Binding AppVersion}" VerticalAlignment="Center"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Classes="field-label" Text="Data"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Classes="path-mono"
|
||||
Text="{Binding DataFolderPath}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Row="1" Grid.Column="2" Content="Open"
|
||||
Command="{Binding OpenPathCommand}"
|
||||
CommandParameter="{Binding DataFolderPath}"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Classes="field-label" Text="Logs"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" Classes="path-mono"
|
||||
Text="{Binding LogsFolderPath}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Row="2" Grid.Column="2" Content="Open"
|
||||
Command="{Binding OpenPathCommand}"
|
||||
CommandParameter="{Binding LogsFolderPath}"/>
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Classes="field-label" Text="Config"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" Classes="path-mono"
|
||||
Text="{Binding WorkerConfigPath}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Row="3" Grid.Column="2" Content="Open"
|
||||
Command="{Binding OpenPathCommand}"
|
||||
CommandParameter="{Binding WorkerConfigPath}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Inline status / error -->
|
||||
<TextBlock Text="{Binding ValidationError}"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
FontSize="11"
|
||||
IsVisible="{Binding ValidationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
FontSize="11"
|
||||
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Text="{Binding Worktrees.StatusMessage}"
|
||||
Foreground="{DynamicResource TextDimBrush}" FontSize="11"
|
||||
IsVisible="{Binding Worktrees.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Files">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="14" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="AGENTS"/>
|
||||
<TextBlock Text="Restore bundled default agents. Existing files are not overwritten."
|
||||
FontSize="11" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextDimBrush}"/>
|
||||
<Button Content="Restore default agents"
|
||||
Command="{Binding Files.RestoreDefaultAgentsCommand}"
|
||||
IsEnabled="{Binding !Files.IsBusy}"
|
||||
HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="PROMPTS"/>
|
||||
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="8">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="System" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono" Text="{Binding Files.SystemPromptPath}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Row="0" Grid.Column="2" Content="Open in editor"
|
||||
Command="{Binding Files.OpenPromptCommand}" CommandParameter="System"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Classes="field-label" Text="Planning" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Classes="path-mono" Text="{Binding Files.PlanningPromptPath}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Row="1" Grid.Column="2" Content="Open in editor"
|
||||
Command="{Binding Files.OpenPromptCommand}" CommandParameter="Planning"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Classes="field-label" Text="Agent" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" Classes="path-mono" Text="{Binding Files.AgentPromptPath}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Row="2" Grid.Column="2" Content="Open in editor"
|
||||
Command="{Binding Files.OpenPromptCommand}" CommandParameter="Agent"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Files.StatusMessage}"
|
||||
Foreground="{DynamicResource TextDimBrush}" FontSize="11"
|
||||
IsVisible="{Binding Files.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Prime Claude">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||||
<TextBlock TextWrapping="Wrap" FontSize="11"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
Text="Prime your Claude usage window each morning by firing a single non-interactive ping at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately."/>
|
||||
<ItemsControl ItemsSource="{Binding Prime.Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="settings:PrimeScheduleRowViewModel">
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="10,8" Margin="0,0,0,8"
|
||||
Background="{DynamicResource DeepBrush}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto,Auto" ColumnSpacing="8">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||
<ctl:ThemedDatePicker Grid.Column="1"
|
||||
IsRange="True"
|
||||
StartDate="{Binding StartDate, Mode=TwoWay, Converter={StaticResource DateOnlyToDateTime}}"
|
||||
EndDate="{Binding EndDate, Mode=TwoWay, Converter={StaticResource DateOnlyToDateTime}}"
|
||||
Watermark="Pick a range"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="2" Width="64"
|
||||
Text="{Binding TimeOfDay, Mode=TwoWay, Converter={StaticResource TimeSpanToHhmm}}"
|
||||
VerticalAlignment="Center"/>
|
||||
<CheckBox Grid.Column="3" Content="Mon–Fri"
|
||||
IsChecked="{Binding WorkdaysOnly, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="4" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextDimBrush}" FontSize="11"
|
||||
MinWidth="80"/>
|
||||
<Button Grid.Column="5" Content="✕"
|
||||
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Content="+ Add schedule" Command="{Binding Prime.AddScheduleCommand}" HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Footer -->
|
||||
<Border Grid.Row="2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Worker.Services;
|
||||
namespace ClaudeDo.Worker.Agents;
|
||||
|
||||
public sealed class AgentFileService
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClaudeDo.Worker.Services;
|
||||
namespace ClaudeDo.Worker.Agents;
|
||||
|
||||
public sealed record SeedResult(int Copied, int Skipped);
|
||||
|
||||
@@ -2,14 +2,60 @@
|
||||
|
||||
ASP.NET Core hosted service that executes tasks via Claude CLI in isolated environments.
|
||||
|
||||
## Folder Layout
|
||||
|
||||
```
|
||||
Worker/
|
||||
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
|
||||
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService
|
||||
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService
|
||||
Worktrees/ — WorktreeMaintenanceService
|
||||
Agents/ — AgentFileService, DefaultAgentSeeder
|
||||
Runner/ — TaskRunner + Claude CLI integration
|
||||
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService
|
||||
External/ — ExternalMcpService
|
||||
Hub/ — WorkerHub, HubBroadcaster
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
|
||||
- **QueueService** — `BackgroundService` with two execution slots:
|
||||
- Queue slot: FIFO sequential processing of "agent"-tagged queued tasks
|
||||
- Override slot: immediate execution via `RunNow(taskId)`
|
||||
- Wake signaling via `SemaphoreSlim`, backstop timer (30s default)
|
||||
- **StaleTaskRecovery** — startup-only service, flips orphaned "running" tasks to "failed"
|
||||
- **TaskStateService** — only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions.
|
||||
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`.
|
||||
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
||||
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
||||
- **External/ExternalMcpService** — always-on MCP tools for general Claude sessions: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask` (with tags), `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`. Auth via optional `X-ClaudeDo-Key` header.
|
||||
|
||||
## Status Model
|
||||
|
||||
`TaskEntity` carries three orthogonal fields. Lifecycle, planning hierarchy, and chain blocking are no longer conflated.
|
||||
|
||||
| Field | Values | Meaning |
|
||||
|---|---|---|
|
||||
| `Status` | `Idle`, `Queued`, `Running`, `Done`, `Failed`, `Cancelled` | Lifecycle only. |
|
||||
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
|
||||
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. |
|
||||
|
||||
Allowed transitions (enforced by `TaskStateService`):
|
||||
|
||||
```
|
||||
Idle → Queued | Running (RunNow)
|
||||
Queued → Running | Cancelled | Idle
|
||||
Running → Done | Failed | Cancelled
|
||||
Done → Idle (re-run)
|
||||
Failed → Idle | Queued
|
||||
Cancelled → Idle | Queued
|
||||
```
|
||||
|
||||
## Planning Flow
|
||||
|
||||
`PlanningSessionManager.FinalizeAsync` is the single path:
|
||||
|
||||
1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized`.
|
||||
2. `PlanningChainCoordinator.SetupChainAsync` attaches the `agent` tag to every child, enqueues child[0], and `BlockOn`s child[i] → child[i-1].
|
||||
3. The first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.
|
||||
|
||||
`TaskRepository.FinalizePlanningAsync` no longer exists. The `Mark*Async` repository helpers are `internal` — only `TaskStateService` calls them.
|
||||
|
||||
## Task Execution Pipeline
|
||||
|
||||
@@ -27,7 +73,7 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir
|
||||
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever permission mode the app settings specify). Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree).
|
||||
- **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
|
||||
- **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser.
|
||||
- **TaskResetService** — discards a failed task's worktree and resets the task row to Manual; preserves run history.
|
||||
- **TaskResetService** — discards a failed task's worktree and resets the task row to Idle; preserves run history.
|
||||
- **WorktreeManager** — creates worktrees at `claudedo/{taskId[:8]}` branches, commits changes with semantic messages, updates DB with head commit and diff stats
|
||||
- **CommitMessageBuilder** — formats `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`
|
||||
- **AgentFileService** — manages `~/.todo-app/agents/*.md` agent definition files; exposes list/refresh via SignalR
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||
@@ -23,4 +27,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
106
src/ClaudeDo.Worker/External/ExternalMcpService.cs
vendored
106
src/ClaudeDo.Worker/External/ExternalMcpService.cs
vendored
@@ -2,7 +2,8 @@ using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Services;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ModelContextProtocol.Server;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
@@ -10,6 +11,8 @@ namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
|
||||
|
||||
public sealed record TagDto(long Id, string Name);
|
||||
|
||||
public sealed record TaskDto(
|
||||
string Id,
|
||||
string ListId,
|
||||
@@ -29,17 +32,23 @@ public sealed class ExternalMcpService
|
||||
private readonly ListRepository _lists;
|
||||
private readonly QueueService _queue;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly TagRepository _tags;
|
||||
private readonly ITaskStateService _state;
|
||||
|
||||
public ExternalMcpService(
|
||||
TaskRepository tasks,
|
||||
ListRepository lists,
|
||||
QueueService queue,
|
||||
HubBroadcaster broadcaster)
|
||||
HubBroadcaster broadcaster,
|
||||
TagRepository tags,
|
||||
ITaskStateService state)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_lists = lists;
|
||||
_queue = queue;
|
||||
_broadcaster = broadcaster;
|
||||
_tags = tags;
|
||||
_state = state;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("List all task lists available in ClaudeDo.")]
|
||||
@@ -82,13 +91,14 @@ public sealed class ExternalMcpService
|
||||
return ToDto(task);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution.")]
|
||||
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. Optional tags are attached on creation; missing tag names auto-create.")]
|
||||
public async Task<TaskDto> AddTask(
|
||||
string listId,
|
||||
string title,
|
||||
string? description,
|
||||
string createdBy,
|
||||
bool queueImmediately,
|
||||
IReadOnlyList<string>? tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(listId))
|
||||
@@ -107,21 +117,57 @@ public sealed class ExternalMcpService
|
||||
ListId = listId,
|
||||
Title = title,
|
||||
Description = description,
|
||||
Status = queueImmediately ? TaskStatus.Queued : TaskStatus.Manual,
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = list.DefaultCommitType,
|
||||
CreatedBy = createdBy,
|
||||
};
|
||||
await _tasks.AddAsync(entity, cancellationToken);
|
||||
|
||||
if (tags is not null && tags.Count > 0)
|
||||
await _tasks.SetTagsAsync(entity.Id, tags, cancellationToken);
|
||||
|
||||
if (queueImmediately)
|
||||
_queue.WakeQueue();
|
||||
{
|
||||
// Routes through TaskStateService so the queue is woken automatically.
|
||||
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
|
||||
if (!enqueue.Ok)
|
||||
throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
|
||||
entity.Status = TaskStatus.Queued;
|
||||
}
|
||||
|
||||
await _broadcaster.TaskUpdated(entity.Id);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Update a task's status. Only 'Manual' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")]
|
||||
[McpServerTool, Description("Update an existing task's title, description, commit type, and/or tags. Pass null to leave a field unchanged. Tags are replaced as a full set when non-null. Refuses if the task is currently Running.")]
|
||||
public async Task<TaskDto> UpdateTask(
|
||||
string taskId,
|
||||
string? title,
|
||||
string? description,
|
||||
string? commitType,
|
||||
IReadOnlyList<string>? tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status == TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot update a running task. Cancel it first.");
|
||||
|
||||
if (title is not null) task.Title = title;
|
||||
if (description is not null) task.Description = description;
|
||||
if (commitType is not null) task.CommitType = commitType;
|
||||
await _tasks.UpdateAsync(task, cancellationToken);
|
||||
|
||||
if (tags is not null)
|
||||
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
|
||||
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return ToDto(reload);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Update a task's status. Only 'Idle' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")]
|
||||
public async Task<TaskDto> UpdateTaskStatus(
|
||||
string taskId,
|
||||
string status,
|
||||
@@ -135,16 +181,15 @@ public sealed class ExternalMcpService
|
||||
|
||||
switch (target)
|
||||
{
|
||||
case TaskStatus.Manual:
|
||||
case TaskStatus.Idle:
|
||||
await _tasks.ResetToManualAsync(taskId, cancellationToken);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
break;
|
||||
|
||||
case TaskStatus.Queued:
|
||||
if (task.Status is TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot enqueue a running task.");
|
||||
task.Status = TaskStatus.Queued;
|
||||
await _tasks.UpdateAsync(task, cancellationToken);
|
||||
_queue.WakeQueue();
|
||||
var enqueueResult = await _state.EnqueueAsync(taskId, cancellationToken);
|
||||
if (!enqueueResult.Ok)
|
||||
throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task.");
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -153,7 +198,6 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return ToDto(reload);
|
||||
}
|
||||
|
||||
@@ -183,6 +227,42 @@ public sealed class ExternalMcpService
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Delete a task. Refuses if the task is currently Running — cancel it first.")]
|
||||
public async Task DeleteTask(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status == TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");
|
||||
|
||||
await _tasks.DeleteAsync(taskId, cancellationToken);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Replace the full tag set on an existing task. Missing tag names auto-create. Refuses if the task is Running.")]
|
||||
public async Task<TaskDto> SetTaskTags(
|
||||
string taskId,
|
||||
IReadOnlyList<string> tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status == TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot retag a running task. Cancel it first.");
|
||||
|
||||
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return ToDto(reload);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("List all known tags. Useful for discovering existing tag names (including 'agent' which marks tasks for auto-execution) before tagging.")]
|
||||
public async Task<IReadOnlyList<TagDto>> ListTags(CancellationToken cancellationToken)
|
||||
{
|
||||
var tags = await _tags.GetAllAsync(cancellationToken);
|
||||
return tags.Select(t => new TagDto(t.Id, t.Name)).ToList();
|
||||
}
|
||||
|
||||
private static TaskDto ToDto(TaskEntity t) => new(
|
||||
t.Id,
|
||||
t.ListId,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.Prime;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ClaudeDo.Worker.Hub;
|
||||
|
||||
public sealed class HubBroadcaster
|
||||
public sealed class HubBroadcaster : IPrimeBroadcaster
|
||||
{
|
||||
private readonly IHubContext<WorkerHub> _hub;
|
||||
|
||||
@@ -47,4 +48,10 @@ public sealed class HubBroadcaster
|
||||
|
||||
public Task PlanningCompleted(string planningTaskId) =>
|
||||
_hub.Clients.All.SendAsync("PlanningCompleted", planningTaskId);
|
||||
|
||||
public Task PrimeFired(Guid scheduleId, bool success, string message, DateTimeOffset firedAt) =>
|
||||
_hub.Clients.All.SendAsync("PrimeFired", scheduleId, success, message, firedAt);
|
||||
|
||||
Task IPrimeBroadcaster.PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt) =>
|
||||
PrimeFired(scheduleId, success, message, firedAt);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@ using System.Reflection;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Agents;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Services;
|
||||
using ClaudeDo.Worker.Prime;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -37,6 +43,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
|
||||
|
||||
private readonly QueueService _queue;
|
||||
private readonly IQueueWaker _waker;
|
||||
private readonly AgentFileService _agentService;
|
||||
private readonly DefaultAgentSeeder _seeder;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
@@ -49,9 +56,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly PlanningAggregator _planningAggregator;
|
||||
private readonly PlanningMergeOrchestrator _planningMergeOrchestrator;
|
||||
private readonly PlanningChainCoordinator _planningChain;
|
||||
private readonly IPrimeScheduleSignal _primeSignal;
|
||||
private readonly ITaskStateService _state;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
IQueueWaker waker,
|
||||
AgentFileService agentService,
|
||||
DefaultAgentSeeder seeder,
|
||||
HubBroadcaster broadcaster,
|
||||
@@ -63,9 +73,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
IPlanningTerminalLauncher launcher,
|
||||
PlanningAggregator planningAggregator,
|
||||
PlanningMergeOrchestrator planningMergeOrchestrator,
|
||||
PlanningChainCoordinator planningChain)
|
||||
PlanningChainCoordinator planningChain,
|
||||
IPrimeScheduleSignal primeSignal,
|
||||
ITaskStateService state)
|
||||
{
|
||||
_queue = queue;
|
||||
_waker = waker;
|
||||
_agentService = agentService;
|
||||
_seeder = seeder;
|
||||
_broadcaster = broadcaster;
|
||||
@@ -78,13 +91,15 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_planningAggregator = planningAggregator;
|
||||
_planningMergeOrchestrator = planningMergeOrchestrator;
|
||||
_planningChain = planningChain;
|
||||
_primeSignal = primeSignal;
|
||||
_state = state;
|
||||
}
|
||||
|
||||
public async Task QueuePlanningSubtasksAsync(string parentTaskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _planningChain.QueueSubtasksSequentiallyAsync(parentTaskId, Context.ConnectionAborted);
|
||||
await _planningChain.SetupChainAsync(parentTaskId, Context.ConnectionAborted);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
@@ -99,8 +114,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
await _broadcaster.TaskUpdated(parentTaskId);
|
||||
foreach (var id in childIds)
|
||||
await _broadcaster.TaskUpdated(id);
|
||||
|
||||
_queue.WakeQueue();
|
||||
}
|
||||
|
||||
public string Ping() => $"pong v{Version}";
|
||||
@@ -162,7 +175,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
|
||||
public bool CancelTask(string taskId) => _queue.CancelTask(taskId);
|
||||
|
||||
public void WakeQueue() => _queue.WakeQueue();
|
||||
public void WakeQueue() => _waker.Wake();
|
||||
|
||||
public async Task<List<AgentInfo>> GetAgents() => await _agentService.ScanAsync();
|
||||
|
||||
@@ -310,6 +323,49 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath);
|
||||
}
|
||||
|
||||
public async Task SetTaskStatus(string taskId, string status)
|
||||
{
|
||||
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
||||
throw new HubException($"unknown status: {status}");
|
||||
var result = await _state.ForceSetStatusAsync(taskId, parsed, Context.ConnectionAborted);
|
||||
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
|
||||
}
|
||||
|
||||
public async Task SetTaskTags(string taskId, string[] tagNames)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity is null) throw new HubException("task not found");
|
||||
|
||||
var desired = (tagNames ?? Array.Empty<string>())
|
||||
.Select(n => n?.Trim().ToLowerInvariant() ?? "")
|
||||
.Where(n => n.Length > 0)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var t in entity.Tags.Where(t => !desired.Contains(t.Name)).ToList())
|
||||
entity.Tags.Remove(t);
|
||||
|
||||
var existingByName = await ctx.Tags
|
||||
.Where(t => desired.Contains(t.Name))
|
||||
.ToListAsync();
|
||||
foreach (var name in desired)
|
||||
{
|
||||
if (entity.Tags.Any(t => t.Name == name)) continue;
|
||||
var tag = existingByName.FirstOrDefault(t => t.Name == name)
|
||||
?? new TagEntity { Name = name };
|
||||
if (tag.Id == 0) ctx.Tags.Add(tag);
|
||||
entity.Tags.Add(tag);
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAllTags()
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
return await ctx.Tags.OrderBy(t => t.Name).Select(t => t.Name).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
@@ -418,5 +474,44 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
|
||||
}
|
||||
|
||||
public async Task<List<PrimeScheduleDto>> ListPrimeSchedules()
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
var rows = await new PrimeScheduleRepository(ctx).ListAsync();
|
||||
return rows.Select(e => new PrimeScheduleDto(
|
||||
e.Id, e.StartDate, e.EndDate, e.TimeOfDay,
|
||||
e.WorkdaysOnly, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList();
|
||||
}
|
||||
|
||||
public async Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
var repo = new PrimeScheduleRepository(ctx);
|
||||
var existing = await repo.GetAsync(dto.Id);
|
||||
var entity = new ClaudeDo.Data.Models.PrimeScheduleEntity
|
||||
{
|
||||
Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id,
|
||||
StartDate = dto.StartDate,
|
||||
EndDate = dto.EndDate,
|
||||
TimeOfDay = dto.TimeOfDay,
|
||||
WorkdaysOnly = dto.WorkdaysOnly,
|
||||
Enabled = dto.Enabled,
|
||||
PromptOverride = dto.PromptOverride,
|
||||
CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
|
||||
LastRunAt = existing?.LastRunAt,
|
||||
};
|
||||
await repo.UpsertAsync(entity);
|
||||
_primeSignal.Signal();
|
||||
return new PrimeScheduleDto(entity.Id, entity.StartDate, entity.EndDate, entity.TimeOfDay,
|
||||
entity.WorkdaysOnly, entity.Enabled, entity.LastRunAt, entity.PromptOverride);
|
||||
}
|
||||
|
||||
public async Task DeletePrimeSchedule(Guid id)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
await new PrimeScheduleRepository(ctx).DeleteAsync(id);
|
||||
_primeSignal.Signal();
|
||||
}
|
||||
|
||||
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
}
|
||||
|
||||
38
src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs
Normal file
38
src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ClaudeDo.Worker.Lifecycle;
|
||||
|
||||
public static class ClaudeCliPreflight
|
||||
{
|
||||
public sealed record Result(bool Ok, string Version, string Error, int ExitCode);
|
||||
|
||||
public static async Task<Result> CheckAsync(string claudeBin, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = claudeBin,
|
||||
Arguments = "--version",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc is null) return new Result(false, "", "Process.Start returned null", -1);
|
||||
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct);
|
||||
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
|
||||
await proc.WaitForExitAsync(ct);
|
||||
|
||||
var stdout = (await stdoutTask).Trim();
|
||||
var stderr = (await stderrTask).Trim();
|
||||
return new Result(proc.ExitCode == 0, stdout, stderr, proc.ExitCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new Result(false, "", ex.Message, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,21 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ClaudeDo.Worker.State;
|
||||
|
||||
namespace ClaudeDo.Worker.Services;
|
||||
namespace ClaudeDo.Worker.Lifecycle;
|
||||
|
||||
public sealed class StaleTaskRecovery : IHostedService
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly ITaskStateService _state;
|
||||
private readonly ILogger<StaleTaskRecovery> _logger;
|
||||
|
||||
public StaleTaskRecovery(IDbContextFactory<ClaudeDoDbContext> dbFactory, ILogger<StaleTaskRecovery> logger)
|
||||
public StaleTaskRecovery(ITaskStateService state, ILogger<StaleTaskRecovery> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_state = state;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var tasks = new TaskRepository(context);
|
||||
var flipped = await tasks.FlipAllRunningToFailedAsync("worker restart", cancellationToken);
|
||||
var flipped = await _state.RecoverStaleRunningAsync("worker restart", cancellationToken);
|
||||
if (flipped > 0)
|
||||
_logger.LogWarning("Stale task recovery: flipped {Count} running task(s) to failed", flipped);
|
||||
else
|
||||
@@ -6,7 +6,7 @@ using ClaudeDo.Worker.Hub;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Services;
|
||||
namespace ClaudeDo.Worker.Lifecycle;
|
||||
|
||||
public sealed record MergeResult(
|
||||
string Status,
|
||||
@@ -3,27 +3,31 @@ using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Services;
|
||||
namespace ClaudeDo.Worker.Lifecycle;
|
||||
|
||||
public sealed class TaskResetService
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly WorktreeManager _wtManager;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly ITaskStateService _state;
|
||||
private readonly ILogger<TaskResetService> _logger;
|
||||
|
||||
public TaskResetService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
WorktreeManager wtManager,
|
||||
HubBroadcaster broadcaster,
|
||||
ITaskStateService state,
|
||||
ILogger<TaskResetService> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_wtManager = wtManager;
|
||||
_broadcaster = broadcaster;
|
||||
_state = state;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -55,16 +59,13 @@ public sealed class TaskResetService
|
||||
worktreeChanged = true;
|
||||
}
|
||||
|
||||
using (var ctx = _dbFactory.CreateDbContext())
|
||||
{
|
||||
await new TaskRepository(ctx).ResetToManualAsync(taskId, ct);
|
||||
}
|
||||
await _state.ResetToIdleAsync(taskId, ct);
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
if (worktreeChanged)
|
||||
await _broadcaster.WorktreeUpdated(taskId);
|
||||
|
||||
_logger.LogInformation("Reset task {TaskId} to Manual (worktree discarded: {Discarded})", taskId, worktreeChanged);
|
||||
_logger.LogInformation("Reset task {TaskId} to Idle (worktree discarded: {Discarded})", taskId, worktreeChanged);
|
||||
await _broadcaster.WorkerLog($"Reset \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
@@ -8,56 +9,95 @@ namespace ClaudeDo.Worker.Planning;
|
||||
public sealed class PlanningChainCoordinator
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly Func<ITaskStateService> _state;
|
||||
|
||||
public PlanningChainCoordinator(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
=> _dbFactory = dbFactory;
|
||||
public PlanningChainCoordinator(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
Func<ITaskStateService> state)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_state = state;
|
||||
}
|
||||
|
||||
public async Task QueueSubtasksSequentiallyAsync(string parentTaskId, CancellationToken ct = default)
|
||||
// Sets up a sequential queue chain over a planning parent's children.
|
||||
// - First non-terminal child gets Status=Queued, BlockedByTaskId=null.
|
||||
// - Each subsequent non-terminal child gets Status=Queued + BlockedByTaskId=<predecessor>,
|
||||
// so the picker skips them until the predecessor finishes.
|
||||
// - Terminal children (Done/Failed/Cancelled) are left untouched; they are
|
||||
// skipped when computing predecessors so a re-run on a partially executed
|
||||
// chain leaves history alone but still reshapes the tail.
|
||||
// - Running children abort the operation — the chain cannot be reshaped while
|
||||
// one of its members is mid-flight.
|
||||
// The "agent" tag is auto-attached to every child so the picker can claim them.
|
||||
// Returns the number of children placed in the chain.
|
||||
internal async Task<int> SetupChainAsync(string parentTaskId, CancellationToken ct = default)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var parent = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == parentTaskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {parentTaskId} not found.");
|
||||
|
||||
var children = await ctx.Tasks
|
||||
.Include(t => t.Tags)
|
||||
.Where(t => t.ParentTaskId == parentTaskId)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
if (children.Count == 0)
|
||||
throw new InvalidOperationException("Parent has no subtasks.");
|
||||
|
||||
var bad = children.FirstOrDefault(c =>
|
||||
c.Status != TaskStatus.Manual && c.Status != TaskStatus.Planned);
|
||||
if (bad is not null)
|
||||
var running = children.FirstOrDefault(c => c.Status == TaskStatus.Running);
|
||||
if (running is not null)
|
||||
throw new InvalidOperationException(
|
||||
$"Child {bad.Id} is in status {bad.Status}; expected Manual or Planned.");
|
||||
|
||||
for (int i = 0; i < children.Count; i++)
|
||||
children[i].Status = i == 0 ? TaskStatus.Queued : TaskStatus.Waiting;
|
||||
$"Child {running.Id} is running; cannot reshape chain.");
|
||||
|
||||
// Worker queue picker requires the "agent" tag — attach it so children are pickable.
|
||||
var agentTag = await ctx.Tags.FirstOrDefaultAsync(t => t.Name == "agent", ct);
|
||||
if (agentTag is not null)
|
||||
{
|
||||
foreach (var c in children)
|
||||
{
|
||||
if (!c.Tags.Any(t => t.Id == agentTag.Id))
|
||||
c.Tags.Add(agentTag);
|
||||
}
|
||||
await ctx.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Re-shape over Idle and Queued children only; leave Done/Failed/Cancelled
|
||||
// (terminal) results in place.
|
||||
var sequenceable = children
|
||||
.Where(c => c.Status == TaskStatus.Idle || c.Status == TaskStatus.Queued)
|
||||
.ToList();
|
||||
|
||||
var state = _state();
|
||||
for (int i = 0; i < sequenceable.Count; i++)
|
||||
{
|
||||
await state.EnqueueAsync(sequenceable[i].Id, ct);
|
||||
if (i == 0)
|
||||
await state.UnblockAsync(sequenceable[i].Id, ct);
|
||||
else
|
||||
await state.BlockOnAsync(sequenceable[i].Id, sequenceable[i - 1].Id, ct);
|
||||
}
|
||||
|
||||
return sequenceable.Count;
|
||||
}
|
||||
|
||||
public async Task<string?> OnChildFinishedAsync(
|
||||
string childTaskId, TaskStatus finalStatus, CancellationToken ct = default)
|
||||
{
|
||||
if (finalStatus != TaskStatus.Done) return null;
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var child = await ctx.Tasks
|
||||
// The successor is whichever sibling explicitly blocks on this child.
|
||||
// No status check — UnblockAsync flips legacy Waiting to Queued and is a no-op
|
||||
// for already-Queued rows in the new layout.
|
||||
var nextId = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == childTaskId, ct);
|
||||
if (child?.ParentTaskId is null) return null;
|
||||
|
||||
var next = await ctx.Tasks
|
||||
.Where(t => t.ParentTaskId == child.ParentTaskId
|
||||
&& t.SortOrder > child.SortOrder
|
||||
&& t.Status == TaskStatus.Waiting)
|
||||
.OrderBy(t => t.SortOrder)
|
||||
.Where(t => t.BlockedByTaskId == childTaskId)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.Select(t => t.Id)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (next is null) return null;
|
||||
if (nextId is null) return null;
|
||||
|
||||
next.Status = TaskStatus.Queued;
|
||||
await ctx.SaveChangesAsync(ct);
|
||||
return next.Id;
|
||||
await _state().UnblockAsync(nextId, ct);
|
||||
return nextId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ModelContextProtocol.Server;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
@@ -16,15 +17,21 @@ public sealed class PlanningMcpService
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly PlanningMcpContextAccessor _contextAccessor;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly ITaskStateService _state;
|
||||
private readonly PlanningChainCoordinator _chain;
|
||||
|
||||
public PlanningMcpService(
|
||||
TaskRepository tasks,
|
||||
PlanningMcpContextAccessor contextAccessor,
|
||||
HubBroadcaster broadcaster)
|
||||
HubBroadcaster broadcaster,
|
||||
ITaskStateService state,
|
||||
PlanningChainCoordinator chain)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_contextAccessor = contextAccessor;
|
||||
_broadcaster = broadcaster;
|
||||
_state = state;
|
||||
_chain = chain;
|
||||
}
|
||||
|
||||
private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct)
|
||||
@@ -42,7 +49,7 @@ public sealed class PlanningMcpService
|
||||
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(child.Id, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
return new CreatedChildDto(child.Id, "Draft");
|
||||
return new CreatedChildDto(child.Id, child.Status.ToString());
|
||||
}
|
||||
|
||||
[McpServerTool, Description("List all child tasks under the current planning session's parent task.")]
|
||||
@@ -60,27 +67,41 @@ public sealed class PlanningMcpService
|
||||
return list;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Update an existing draft child task. Only Draft tasks may be modified.")]
|
||||
private static readonly TaskStatus[] EditableStatuses =
|
||||
{ TaskStatus.Idle, TaskStatus.Queued };
|
||||
|
||||
[McpServerTool, Description("Update a child task in the active planning session. Can change title, description, tags (replaces the full set), commit type, and status. Status must be one of: Idle, Queued.")]
|
||||
public async Task<ChildTaskDto> UpdateChildTask(
|
||||
string taskId,
|
||||
string? title,
|
||||
string? description,
|
||||
IReadOnlyList<string>? tags,
|
||||
string? commitType,
|
||||
string? status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken)
|
||||
?? throw new InvalidOperationException("Planning parent task not found.");
|
||||
if (parent.PlanningPhase != PlanningPhase.Active)
|
||||
throw new InvalidOperationException("Cannot modify tasks outside an active planning session.");
|
||||
|
||||
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (child.ParentTaskId != ctx.ParentTaskId)
|
||||
throw new InvalidOperationException("Task is not a child of this planning session.");
|
||||
if (child.Status != TaskStatus.Draft)
|
||||
throw new InvalidOperationException("Cannot modify a finalized task.");
|
||||
|
||||
if (title is not null) child.Title = title;
|
||||
if (description is not null) child.Description = description;
|
||||
if (commitType is not null) child.CommitType = commitType;
|
||||
await _tasks.UpdateAsync(child, cancellationToken);
|
||||
TaskStatus? newStatus = null;
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
||||
throw new InvalidOperationException($"Unknown status '{status}'.");
|
||||
if (!EditableStatuses.Contains(parsed))
|
||||
throw new InvalidOperationException($"Status '{parsed}' cannot be set via MCP. Allowed: Idle, Queued.");
|
||||
newStatus = parsed;
|
||||
}
|
||||
|
||||
await _tasks.UpdateChildAsync(taskId, title, description, commitType, tags, newStatus, cancellationToken);
|
||||
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken);
|
||||
@@ -89,18 +110,21 @@ public sealed class PlanningMcpService
|
||||
return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList());
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Delete a draft child task. Only Draft tasks may be deleted.")]
|
||||
[McpServerTool, Description("Delete a child task in the active planning session.")]
|
||||
public async Task DeleteChildTask(
|
||||
string taskId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken)
|
||||
?? throw new InvalidOperationException("Planning parent task not found.");
|
||||
if (parent.PlanningPhase != PlanningPhase.Active)
|
||||
throw new InvalidOperationException("Cannot delete tasks outside an active planning session.");
|
||||
|
||||
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (child.ParentTaskId != ctx.ParentTaskId)
|
||||
throw new InvalidOperationException("Task is not a child of this planning session.");
|
||||
if (child.Status != TaskStatus.Draft)
|
||||
throw new InvalidOperationException("Cannot delete a finalized task.");
|
||||
|
||||
await _tasks.DeleteAsync(taskId, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(taskId, cancellationToken);
|
||||
@@ -124,11 +148,19 @@ public sealed class PlanningMcpService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
var childIds = (await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken))
|
||||
.Select(c => c.Id).ToList();
|
||||
var count = await _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken);
|
||||
foreach (var id in childIds)
|
||||
await BroadcastTaskUpdatedAsync(id, cancellationToken);
|
||||
|
||||
var finalizeResult = await _state.FinalizePlanningAsync(ctx.ParentTaskId, cancellationToken);
|
||||
if (!finalizeResult.Ok)
|
||||
throw new InvalidOperationException(
|
||||
finalizeResult.Reason ?? $"Could not finalize planning for task {ctx.ParentTaskId}.");
|
||||
|
||||
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
|
||||
int count = children.Count;
|
||||
if (queueAgentTasks && children.Count > 0)
|
||||
count = await _chain.SetupChainAsync(ctx.ParentTaskId, cancellationToken);
|
||||
|
||||
foreach (var c in children)
|
||||
await BroadcastTaskUpdatedAsync(c.Id, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Services;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
@@ -22,17 +23,23 @@ public sealed class PlanningSessionManager
|
||||
private readonly GitService _git;
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly string _rootDirectory;
|
||||
private readonly ITaskStateService? _state;
|
||||
private readonly PlanningChainCoordinator? _chain;
|
||||
|
||||
// DI constructor.
|
||||
public PlanningSessionManager(
|
||||
IDbContextFactory<ClaudeDoDbContext> factory,
|
||||
GitService git,
|
||||
WorkerConfig cfg,
|
||||
ITaskStateService state,
|
||||
PlanningChainCoordinator chain,
|
||||
string rootDirectory)
|
||||
{
|
||||
_factory = factory;
|
||||
_git = git;
|
||||
_cfg = cfg;
|
||||
_state = state;
|
||||
_chain = chain;
|
||||
_rootDirectory = rootDirectory;
|
||||
}
|
||||
|
||||
@@ -43,13 +50,17 @@ public sealed class PlanningSessionManager
|
||||
AppSettingsRepository settings,
|
||||
GitService git,
|
||||
WorkerConfig cfg,
|
||||
string rootDirectory)
|
||||
string rootDirectory,
|
||||
ITaskStateService? state = null,
|
||||
PlanningChainCoordinator? chain = null)
|
||||
{
|
||||
_tasksOverride = tasks;
|
||||
_listsOverride = lists;
|
||||
_settingsOverride = settings;
|
||||
_git = git;
|
||||
_cfg = cfg;
|
||||
_state = state;
|
||||
_chain = chain;
|
||||
_rootDirectory = rootDirectory;
|
||||
}
|
||||
|
||||
@@ -70,8 +81,9 @@ public sealed class PlanningSessionManager
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.ParentTaskId is not null)
|
||||
throw new InvalidOperationException("Cannot start a planning session on a child task.");
|
||||
if (task.Status != TaskStatus.Manual)
|
||||
throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
|
||||
if (task.Status != TaskStatus.Idle || task.PlanningPhase != PlanningPhase.None)
|
||||
throw new InvalidOperationException(
|
||||
$"Task is in status {task.Status}/{task.PlanningPhase}; only Idle+None can start planning.");
|
||||
|
||||
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||
@@ -114,8 +126,19 @@ public sealed class PlanningSessionManager
|
||||
|
||||
// Session dir + token + prompt files.
|
||||
var token = GenerateToken();
|
||||
var started = await tasks.SetPlanningStartedAsync(taskId, token, ct)
|
||||
?? throw new InvalidOperationException("Failed to transition task to Planning.");
|
||||
if (_state is not null)
|
||||
{
|
||||
var startResult = await _state.StartPlanningAsync(taskId, ct);
|
||||
if (!startResult.Ok)
|
||||
throw new InvalidOperationException(startResult.Reason ?? "Failed to transition task to Planning.");
|
||||
await tasks.SetPlanningSessionTokenAsync(taskId, token, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Test fallback when no state-service is provided.
|
||||
if (await tasks.SetPlanningStartedAsync(taskId, token, ct) is null)
|
||||
throw new InvalidOperationException("Failed to transition task to Planning.");
|
||||
}
|
||||
|
||||
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||
Directory.CreateDirectory(sessionDir);
|
||||
@@ -177,7 +200,21 @@ public sealed class PlanningSessionManager
|
||||
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||
await using var __ = ctx;
|
||||
|
||||
var count = await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
|
||||
if (_state is null || _chain is null)
|
||||
throw new InvalidOperationException(
|
||||
"PlanningSessionManager.FinalizeAsync requires ITaskStateService and PlanningChainCoordinator.");
|
||||
|
||||
var finalizeResult = await _state.FinalizePlanningAsync(taskId, ct);
|
||||
if (!finalizeResult.Ok)
|
||||
throw new InvalidOperationException(
|
||||
finalizeResult.Reason ?? $"Could not finalize planning for task {taskId}.");
|
||||
|
||||
int count = 0;
|
||||
var children = await tasks.GetChildrenAsync(taskId, ct);
|
||||
if (queueAgentTasks && children.Count > 0)
|
||||
count = await _chain.SetupChainAsync(taskId, ct);
|
||||
else
|
||||
count = children.Count;
|
||||
|
||||
// Best-effort cleanup — don't block finalization on git state.
|
||||
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||
@@ -196,7 +233,7 @@ public sealed class PlanningSessionManager
|
||||
var (tasks, _, settings, ctx) = CreateRepos();
|
||||
await using var __ = ctx;
|
||||
var children = await tasks.GetChildrenAsync(taskId, ct);
|
||||
return children.Count(c => c.Status == TaskStatus.Draft);
|
||||
return children.Count(c => c.Status == TaskStatus.Idle);
|
||||
}
|
||||
|
||||
public async Task DiscardAsync(string taskId, CancellationToken ct)
|
||||
@@ -225,8 +262,9 @@ public sealed class PlanningSessionManager
|
||||
|
||||
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status != TaskStatus.Planning)
|
||||
throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
|
||||
if (task.PlanningPhase != PlanningPhase.Active)
|
||||
throw new InvalidOperationException(
|
||||
$"Task planning phase is {task.PlanningPhase}; resume requires Active planning.");
|
||||
if (string.IsNullOrEmpty(task.PlanningSessionId))
|
||||
throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class PlanningTokenAuthMiddleware
|
||||
|
||||
var token = auth.Substring("Bearer ".Length).Trim();
|
||||
var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted);
|
||||
if (parent is null || parent.Status != ClaudeDo.Data.Models.TaskStatus.Planning)
|
||||
if (parent is null || parent.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.Active)
|
||||
{
|
||||
ctx.Response.StatusCode = 401;
|
||||
await ctx.Response.WriteAsync("Invalid or expired planning token");
|
||||
|
||||
6
src/ClaudeDo.Worker/Prime/IPrimeBroadcaster.cs
Normal file
6
src/ClaudeDo.Worker/Prime/IPrimeBroadcaster.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ClaudeDo.Worker.Prime;
|
||||
|
||||
public interface IPrimeBroadcaster
|
||||
{
|
||||
Task PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt);
|
||||
}
|
||||
2
src/ClaudeDo.Worker/Prime/IPrimeClock.cs
Normal file
2
src/ClaudeDo.Worker/Prime/IPrimeClock.cs
Normal file
@@ -0,0 +1,2 @@
|
||||
namespace ClaudeDo.Worker.Prime;
|
||||
public interface IPrimeClock { DateTimeOffset Now { get; } }
|
||||
8
src/ClaudeDo.Worker/Prime/IPrimeRunner.cs
Normal file
8
src/ClaudeDo.Worker/Prime/IPrimeRunner.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ClaudeDo.Worker.Prime;
|
||||
|
||||
public interface IPrimeRunner
|
||||
{
|
||||
Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record PrimeRunOutcome(bool Success, string Message);
|
||||
6
src/ClaudeDo.Worker/Prime/IPrimeScheduleSignal.cs
Normal file
6
src/ClaudeDo.Worker/Prime/IPrimeScheduleSignal.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ClaudeDo.Worker.Prime;
|
||||
public interface IPrimeScheduleSignal
|
||||
{
|
||||
void Signal();
|
||||
CancellationToken CurrentToken { get; }
|
||||
}
|
||||
66
src/ClaudeDo.Worker/Prime/NextDueCalculator.cs
Normal file
66
src/ClaudeDo.Worker/Prime/NextDueCalculator.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace ClaudeDo.Worker.Prime;
|
||||
|
||||
public sealed record NextDue(PrimeScheduleDto Schedule, DateTimeOffset At, bool FireImmediately);
|
||||
|
||||
public static class NextDueCalculator
|
||||
{
|
||||
public static NextDue? Compute(
|
||||
IEnumerable<PrimeScheduleDto> schedules,
|
||||
DateTimeOffset now,
|
||||
TimeSpan catchUp)
|
||||
{
|
||||
NextDue? best = null;
|
||||
foreach (var s in schedules)
|
||||
{
|
||||
if (!s.Enabled) continue;
|
||||
var due = ComputeFor(s, now, catchUp);
|
||||
if (due is null) continue;
|
||||
if (best is null || due.At < best.At) best = due;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static NextDue? ComputeFor(PrimeScheduleDto s, DateTimeOffset now, TimeSpan catchUp)
|
||||
{
|
||||
if (s.EndDate < DateOnly.FromDateTime(now.LocalDateTime)) return null;
|
||||
|
||||
var todayLocal = DateOnly.FromDateTime(now.LocalDateTime);
|
||||
var alreadyFiredToday = s.LastRunAt is { } last &&
|
||||
DateOnly.FromDateTime(last.LocalDateTime) == todayLocal;
|
||||
|
||||
if (!alreadyFiredToday)
|
||||
{
|
||||
var startOrToday = s.StartDate > todayLocal ? s.StartDate : todayLocal;
|
||||
if (startOrToday == todayLocal && IsEligibleDay(s, todayLocal))
|
||||
{
|
||||
var todayTarget = ToOffset(todayLocal, s.TimeOfDay, now.Offset);
|
||||
if (todayTarget >= now)
|
||||
return new NextDue(s, todayTarget, false);
|
||||
if (now <= todayTarget + catchUp)
|
||||
return new NextDue(s, now, true);
|
||||
}
|
||||
}
|
||||
|
||||
var d = todayLocal.AddDays(1);
|
||||
if (s.StartDate > d) d = s.StartDate;
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
if (d > s.EndDate) return null;
|
||||
if (IsEligibleDay(s, d))
|
||||
return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false);
|
||||
d = d.AddDays(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsEligibleDay(PrimeScheduleDto s, DateOnly d)
|
||||
{
|
||||
if (d < s.StartDate || d > s.EndDate) return false;
|
||||
if (!s.WorkdaysOnly) return true;
|
||||
var dow = d.ToDateTime(TimeOnly.MinValue).DayOfWeek;
|
||||
return dow != DayOfWeek.Saturday && dow != DayOfWeek.Sunday;
|
||||
}
|
||||
|
||||
private static DateTimeOffset ToOffset(DateOnly day, TimeSpan time, TimeSpan offset) =>
|
||||
new(day.Year, day.Month, day.Day, time.Hours, time.Minutes, time.Seconds, offset);
|
||||
}
|
||||
5
src/ClaudeDo.Worker/Prime/PrimeClock.cs
Normal file
5
src/ClaudeDo.Worker/Prime/PrimeClock.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace ClaudeDo.Worker.Prime;
|
||||
public sealed class PrimeClock : IPrimeClock
|
||||
{
|
||||
public DateTimeOffset Now => DateTimeOffset.Now;
|
||||
}
|
||||
53
src/ClaudeDo.Worker/Prime/PrimeRunner.cs
Normal file
53
src/ClaudeDo.Worker/Prime/PrimeRunner.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
|
||||
namespace ClaudeDo.Worker.Prime;
|
||||
|
||||
public sealed class PrimeRunner : IPrimeRunner
|
||||
{
|
||||
private static readonly TimeSpan FireTimeout = TimeSpan.FromSeconds(60);
|
||||
private readonly IClaudeProcess _claude;
|
||||
private readonly ILogger<PrimeRunner> _logger;
|
||||
|
||||
public PrimeRunner(IClaudeProcess claude, ILogger<PrimeRunner> logger)
|
||||
{
|
||||
_claude = claude;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
|
||||
{
|
||||
var cwd = Paths.AppDataRoot();
|
||||
Directory.CreateDirectory(cwd);
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(FireTimeout);
|
||||
|
||||
try
|
||||
{
|
||||
var prompt = schedule.PromptOverride ?? "ping";
|
||||
var result = await _claude.RunAsync(
|
||||
arguments: "-p --max-turns 1",
|
||||
prompt: prompt,
|
||||
workingDirectory: cwd,
|
||||
onStdoutLine: _ => Task.CompletedTask,
|
||||
ct: timeoutCts.Token);
|
||||
|
||||
if (IsSuccess(result))
|
||||
return new PrimeRunOutcome(true, "Primed Claude");
|
||||
return new PrimeRunOutcome(false, FailureMessage(result));
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalSeconds:0}s");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Prime fire failed");
|
||||
return new PrimeRunOutcome(false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSuccess(RunResult result) => result.IsSuccess;
|
||||
private static string FailureMessage(RunResult result) => $"exit code {result.ExitCode}";
|
||||
}
|
||||
11
src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs
Normal file
11
src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace ClaudeDo.Worker.Prime;
|
||||
|
||||
public sealed record PrimeScheduleDto(
|
||||
Guid Id,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
TimeSpan TimeOfDay,
|
||||
bool WorkdaysOnly,
|
||||
bool Enabled,
|
||||
DateTimeOffset? LastRunAt,
|
||||
string? PromptOverride);
|
||||
29
src/ClaudeDo.Worker/Prime/PrimeScheduleSignal.cs
Normal file
29
src/ClaudeDo.Worker/Prime/PrimeScheduleSignal.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace ClaudeDo.Worker.Prime;
|
||||
|
||||
public sealed class PrimeScheduleSignal : IPrimeScheduleSignal, IDisposable
|
||||
{
|
||||
private CancellationTokenSource _cts = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public CancellationToken CurrentToken
|
||||
{
|
||||
get { lock (_lock) return _cts.Token; }
|
||||
}
|
||||
|
||||
public void Signal()
|
||||
{
|
||||
CancellationTokenSource old;
|
||||
lock (_lock)
|
||||
{
|
||||
old = _cts;
|
||||
_cts = new CancellationTokenSource();
|
||||
}
|
||||
try { old.Cancel(); } catch { /* already cancelled */ }
|
||||
old.Dispose();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock) _cts.Dispose();
|
||||
}
|
||||
}
|
||||
111
src/ClaudeDo.Worker/Prime/PrimeScheduler.cs
Normal file
111
src/ClaudeDo.Worker/Prime/PrimeScheduler.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace ClaudeDo.Worker.Prime;
|
||||
|
||||
public sealed class PrimeScheduler : BackgroundService
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IPrimeRunner _runner;
|
||||
private readonly IPrimeClock _clock;
|
||||
private readonly IPrimeScheduleSignal _signal;
|
||||
private readonly IPrimeBroadcaster _broadcaster;
|
||||
private readonly PrimeSchedulerOptions _options;
|
||||
private readonly ILogger<PrimeScheduler> _logger;
|
||||
|
||||
public PrimeScheduler(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
IPrimeRunner runner,
|
||||
IPrimeClock clock,
|
||||
IPrimeScheduleSignal signal,
|
||||
IPrimeBroadcaster broadcaster,
|
||||
PrimeSchedulerOptions options,
|
||||
ILogger<PrimeScheduler> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_runner = runner;
|
||||
_clock = clock;
|
||||
_signal = signal;
|
||||
_broadcaster = broadcaster;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await TickAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "PrimeScheduler tick failed; backing off");
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TickAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var schedules = await LoadAsync(stoppingToken);
|
||||
var now = _clock.Now;
|
||||
var due = NextDueCalculator.Compute(schedules, now, _options.CatchUpWindow);
|
||||
|
||||
var signalToken = _signal.CurrentToken;
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, signalToken);
|
||||
|
||||
if (due is null)
|
||||
{
|
||||
try { await Task.Delay(TimeSpan.FromHours(1), linked.Token); }
|
||||
catch (OperationCanceledException) { /* signal or shutdown */ }
|
||||
return;
|
||||
}
|
||||
|
||||
var delay = due.FireImmediately ? TimeSpan.Zero : due.At - now;
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
try { await Task.Delay(delay, linked.Token); }
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (signalToken.IsCancellationRequested) return;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
await FireAsync(due.Schedule, stoppingToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<PrimeScheduleDto>> LoadAsync(CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var rows = await new PrimeScheduleRepository(ctx).ListAsync(ct);
|
||||
return rows.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) =>
|
||||
new(e.Id, e.StartDate, e.EndDate, e.TimeOfDay, e.WorkdaysOnly, e.Enabled, e.LastRunAt, e.PromptOverride);
|
||||
|
||||
private async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
|
||||
{
|
||||
var firedAt = _clock.Now;
|
||||
var outcome = await _runner.FireAsync(schedule, ct);
|
||||
|
||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||
await new PrimeScheduleRepository(ctx).UpdateLastRunAsync(schedule.Id, firedAt, ct);
|
||||
|
||||
await _broadcaster.PrimeFiredAsync(schedule.Id, outcome.Success, outcome.Message, firedAt);
|
||||
|
||||
if (outcome.Success)
|
||||
_logger.LogInformation("Prime fired {Id} at {When}", schedule.Id, firedAt);
|
||||
else
|
||||
_logger.LogWarning("Prime failed {Id}: {Msg}", schedule.Id, outcome.Message);
|
||||
}
|
||||
}
|
||||
7
src/ClaudeDo.Worker/Prime/PrimeSchedulerOptions.cs
Normal file
7
src/ClaudeDo.Worker/Prime/PrimeSchedulerOptions.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ClaudeDo.Worker.Prime;
|
||||
|
||||
public sealed record PrimeSchedulerOptions(TimeSpan CatchUpWindow)
|
||||
{
|
||||
public static PrimeSchedulerOptions Default { get; } =
|
||||
new(TimeSpan.FromMinutes(30));
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Agents;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Services;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.Prime;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var cfg = WorkerConfig.Load();
|
||||
@@ -41,6 +46,21 @@ builder.Services.AddSingleton<PlanningAggregator>();
|
||||
builder.Services.AddSingleton<PlanningMergeOrchestrator>();
|
||||
builder.Services.AddSingleton<PlanningChainCoordinator>();
|
||||
|
||||
// Queue dispatch primitives. QueueWaker holds the wake semaphore; the queue picker
|
||||
// performs atomic Queued→Running claim. Both injected into the state service so it
|
||||
// can wake the dispatcher without depending on QueueService directly.
|
||||
builder.Services.AddSingleton<QueueWaker>();
|
||||
builder.Services.AddSingleton<IQueueWaker>(sp => sp.GetRequiredService<QueueWaker>());
|
||||
builder.Services.AddSingleton<IQueuePicker, QueuePicker>();
|
||||
|
||||
builder.Services.AddSingleton<Func<ITaskStateService>>(sp => () => sp.GetRequiredService<ITaskStateService>());
|
||||
builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<HubBroadcaster>(),
|
||||
sp.GetRequiredService<IQueueWaker>(),
|
||||
sp.GetRequiredService<PlanningChainCoordinator>(),
|
||||
sp.GetRequiredService<ILogger<TaskStateService>>()));
|
||||
|
||||
// Agent file management.
|
||||
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
||||
Directory.CreateDirectory(agentsDir);
|
||||
@@ -52,6 +72,18 @@ builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
|
||||
agentsDir,
|
||||
sp.GetService<Microsoft.Extensions.Logging.ILogger<DefaultAgentSeeder>>()));
|
||||
|
||||
// Override slot owns RunNow / ContinueTask. Queue slot is the BackgroundService.
|
||||
builder.Services.AddSingleton<OverrideSlotService>();
|
||||
|
||||
// Prime Claude
|
||||
builder.Services.AddSingleton<IPrimeClock, PrimeClock>();
|
||||
builder.Services.AddSingleton<PrimeScheduleSignal>();
|
||||
builder.Services.AddSingleton<IPrimeScheduleSignal>(sp => sp.GetRequiredService<PrimeScheduleSignal>());
|
||||
builder.Services.AddSingleton<IPrimeRunner, PrimeRunner>();
|
||||
builder.Services.AddSingleton(PrimeSchedulerOptions.Default);
|
||||
builder.Services.AddSingleton<IPrimeBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
|
||||
builder.Services.AddHostedService<PrimeScheduler>();
|
||||
|
||||
// QueueService: singleton + hosted service (same instance).
|
||||
builder.Services.AddSingleton<QueueService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
|
||||
@@ -65,6 +97,8 @@ builder.Services.AddSingleton(sp =>
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<GitService>(),
|
||||
cfg,
|
||||
sp.GetRequiredService<ITaskStateService>(),
|
||||
sp.GetRequiredService<PlanningChainCoordinator>(),
|
||||
planningSessionsDir));
|
||||
builder.Services.AddSingleton<IPlanningTerminalLauncher>(sp =>
|
||||
new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin));
|
||||
@@ -107,6 +141,22 @@ app.UseMiddleware<PlanningTokenAuthMiddleware>();
|
||||
app.MapHub<WorkerHub>("/hub");
|
||||
app.MapMcp("/mcp");
|
||||
|
||||
// Claude CLI preflight: fail fast if the configured binary is unreachable or non-zero.
|
||||
// Skippable via CLAUDEDO_SKIP_CLI_PREFLIGHT=1 for environments without the CLI (e.g. tests).
|
||||
if (Environment.GetEnvironmentVariable("CLAUDEDO_SKIP_CLI_PREFLIGHT") != "1")
|
||||
{
|
||||
var preflight = await ClaudeCliPreflight.CheckAsync(cfg.ClaudeBin);
|
||||
if (!preflight.Ok)
|
||||
{
|
||||
app.Logger.LogCritical(
|
||||
"Claude CLI preflight failed (bin: '{Bin}', exit: {Exit}): {Error}. " +
|
||||
"Fix `claude_bin` in worker.config.json or set CLAUDEDO_SKIP_CLI_PREFLIGHT=1 to bypass.",
|
||||
cfg.ClaudeBin, preflight.ExitCode, preflight.Error);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
app.Logger.LogInformation("Claude CLI preflight OK: {Version}", preflight.Version);
|
||||
}
|
||||
|
||||
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
||||
cfg.SignalRPort, cfg.DbPath);
|
||||
|
||||
@@ -122,11 +172,15 @@ if (cfg.ExternalMcpPort > 0)
|
||||
externalBuilder.Services.AddSingleton(cfg);
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<HubBroadcaster>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<QueueService>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<OverrideSlotService>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<ITaskStateService>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IQueueWaker>());
|
||||
externalBuilder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||
externalBuilder.Services.AddScoped<TaskRepository>();
|
||||
externalBuilder.Services.AddScoped<ListRepository>();
|
||||
externalBuilder.Services.AddScoped<TagRepository>();
|
||||
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
||||
externalBuilder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
|
||||
12
src/ClaudeDo.Worker/Queue/IQueuePicker.cs
Normal file
12
src/ClaudeDo.Worker/Queue/IQueuePicker.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Atomic queue claim. Returns the claimed task (already flipped to Running with
|
||||
/// StartedAt set) or null if no eligible task is available.
|
||||
/// </summary>
|
||||
public interface IQueuePicker
|
||||
{
|
||||
Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct);
|
||||
}
|
||||
11
src/ClaudeDo.Worker/Queue/IQueueWaker.cs
Normal file
11
src/ClaudeDo.Worker/Queue/IQueueWaker.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Signals the queue dispatcher to check for new work. Wake() is non-blocking and
|
||||
/// idempotent — multiple calls before the dispatcher consumes the signal collapse
|
||||
/// into a single wake-up.
|
||||
/// </summary>
|
||||
public interface IQueueWaker
|
||||
{
|
||||
void Wake();
|
||||
}
|
||||
134
src/ClaudeDo.Worker/Queue/OverrideSlotService.cs
Normal file
134
src/ClaudeDo.Worker/Queue/OverrideSlotService.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
public sealed class OverrideSlotService
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly TaskRunner _runner;
|
||||
private readonly ILogger<OverrideSlotService> _logger;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private volatile QueueSlotState? _slot;
|
||||
|
||||
public OverrideSlotService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
TaskRunner runner,
|
||||
ILogger<OverrideSlotService> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_runner = runner;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public QueueSlotState? CurrentSlot => _slot;
|
||||
|
||||
public async Task RunNow(string taskId)
|
||||
{
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var taskRepo = new TaskRepository(context);
|
||||
var exists = await taskRepo.GetByIdAsync(taskId);
|
||||
if (exists is null)
|
||||
throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_slot is not null)
|
||||
throw new InvalidOperationException("override slot busy");
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_slot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
|
||||
|
||||
_ = RunInSlotAsync(taskId, cts.Token).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId}", taskId);
|
||||
lock (_lock) { _slot = null; }
|
||||
cts.Dispose();
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> ContinueTask(string taskId, string followUpPrompt)
|
||||
{
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var taskRepo = new TaskRepository(context);
|
||||
var task = await taskRepo.GetByIdAsync(taskId)
|
||||
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||
|
||||
if (task.Status == Data.Models.TaskStatus.Running)
|
||||
throw new InvalidOperationException("task is already running");
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_slot is not null)
|
||||
throw new InvalidOperationException("override slot busy");
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_slot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
|
||||
|
||||
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "RunContinueInSlotAsync failed for task {TaskId}", taskId);
|
||||
lock (_lock) { _slot = null; }
|
||||
cts.Dispose();
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public bool TryCancel(string taskId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_slot is not null && _slot.TaskId == taskId)
|
||||
{
|
||||
_slot.Cts.Cancel();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task RunInSlotAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting task {TaskId} in override slot", taskId);
|
||||
|
||||
Data.Models.TaskEntity task;
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var taskRepo = new TaskRepository(context);
|
||||
task = await taskRepo.GetByIdAsync(taskId, ct)
|
||||
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||
}
|
||||
|
||||
await _runner.RunAsync(task, "override", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Override slot runner error for task {TaskId}", taskId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunContinueInSlotAsync(string taskId, string followUpPrompt, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Continuing task {TaskId} in override slot", taskId);
|
||||
await _runner.ContinueAsync(taskId, followUpPrompt, "override", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Continue runner error for task {TaskId}", taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/ClaudeDo.Worker/Queue/QueuePicker.cs
Normal file
39
src/ClaudeDo.Worker/Queue/QueuePicker.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
public sealed class QueuePicker : IQueuePicker
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
|
||||
public QueuePicker(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
=> _dbFactory = dbFactory;
|
||||
|
||||
public async Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct)
|
||||
{
|
||||
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
|
||||
// Raw SQL because EF cannot express UPDATE...RETURNING.
|
||||
// Eligible task must be Queued, unblocked, and due (or unscheduled).
|
||||
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||
var startedAtStr = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||
|
||||
var rows = await ctx.Tasks.FromSqlRaw("""
|
||||
UPDATE tasks SET status = 'running', started_at = {1}
|
||||
WHERE id = (
|
||||
SELECT t.id FROM tasks t
|
||||
WHERE t.status = 'queued'
|
||||
AND t.blocked_by_task_id IS NULL
|
||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
|
||||
ORDER BY t.sort_order ASC, t.created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING *
|
||||
""", nowStr, startedAtStr).ToListAsync(ct);
|
||||
|
||||
return rows.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
159
src/ClaudeDo.Worker/Queue/QueueService.cs
Normal file
159
src/ClaudeDo.Worker/Queue/QueueService.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
public sealed class QueueService : BackgroundService
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly TaskRunner _runner;
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly ILogger<QueueService> _logger;
|
||||
private readonly QueueWaker _waker;
|
||||
private readonly IQueuePicker _picker;
|
||||
private readonly OverrideSlotService _override;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private volatile QueueSlotState? _queueSlot;
|
||||
|
||||
public QueueService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
TaskRunner runner,
|
||||
WorkerConfig cfg,
|
||||
ILogger<QueueService> logger,
|
||||
QueueWaker waker,
|
||||
IQueuePicker picker,
|
||||
OverrideSlotService overrideSlot)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_runner = runner;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
_waker = waker;
|
||||
_picker = picker;
|
||||
_override = overrideSlot;
|
||||
}
|
||||
|
||||
public IReadOnlyList<(string slot, string taskId, DateTime startedAt)> GetActive()
|
||||
{
|
||||
var list = new List<(string, string, DateTime)>();
|
||||
var q = _queueSlot;
|
||||
if (q is not null) list.Add(("queue", q.TaskId, q.StartedAt));
|
||||
var o = _override.CurrentSlot;
|
||||
if (o is not null) list.Add(("override", o.TaskId, o.StartedAt));
|
||||
return list;
|
||||
}
|
||||
|
||||
public Task RunNow(string taskId)
|
||||
{
|
||||
EnsureNotInQueueSlot(taskId);
|
||||
return _override.RunNow(taskId);
|
||||
}
|
||||
|
||||
public Task<string> ContinueTask(string taskId, string followUpPrompt)
|
||||
{
|
||||
EnsureNotInQueueSlot(taskId);
|
||||
return _override.ContinueTask(taskId, followUpPrompt);
|
||||
}
|
||||
|
||||
private void EnsureNotInQueueSlot(string taskId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_queueSlot?.TaskId == taskId)
|
||||
throw new InvalidOperationException("task is already running in queue slot");
|
||||
}
|
||||
}
|
||||
|
||||
public bool CancelTask(string taskId)
|
||||
{
|
||||
if (_override.TryCancel(taskId)) return true;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_queueSlot is not null && _queueSlot.TaskId == taskId)
|
||||
{
|
||||
_queueSlot.Cts.Cancel();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("QueueService started");
|
||||
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_cfg.QueueBackstopIntervalMs));
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Wait for wake signal or backstop timer.
|
||||
var wakeTask = _waker.WaitAsync(stoppingToken);
|
||||
var timerTask = timer.WaitForNextTickAsync(stoppingToken).AsTask();
|
||||
|
||||
await Task.WhenAny(wakeTask, timerTask);
|
||||
|
||||
if (_queueSlot is not null) continue;
|
||||
|
||||
var task = await _picker.ClaimNextAsync(DateTime.UtcNow, stoppingToken);
|
||||
if (task is null) continue;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_queueSlot is not null) continue;
|
||||
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
_queueSlot = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts };
|
||||
|
||||
_ = RunInSlotAsync(task.Id, cts.Token).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id);
|
||||
lock (_lock) { _queueSlot = null; }
|
||||
cts.Dispose();
|
||||
_waker.Wake(); // Check for next task immediately.
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "QueueService loop error");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("QueueService stopping");
|
||||
}
|
||||
|
||||
private async Task RunInSlotAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting task {TaskId} in queue slot", taskId);
|
||||
|
||||
TaskEntity task;
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var taskRepo = new TaskRepository(context);
|
||||
task = await taskRepo.GetByIdAsync(taskId, ct)
|
||||
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||
}
|
||||
|
||||
await _runner.RunAsync(task, "queue", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Slot runner error for task {TaskId}", taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/ClaudeDo.Worker/Queue/QueueSlotState.cs
Normal file
8
src/ClaudeDo.Worker/Queue/QueueSlotState.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
public sealed class QueueSlotState
|
||||
{
|
||||
public required string TaskId { get; init; }
|
||||
public required DateTime StartedAt { get; init; }
|
||||
public required CancellationTokenSource Cts { get; init; }
|
||||
}
|
||||
18
src/ClaudeDo.Worker/Queue/QueueWaker.cs
Normal file
18
src/ClaudeDo.Worker/Queue/QueueWaker.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the wake semaphore. Producers (state mutations, hub) call Wake();
|
||||
/// the queue dispatcher awaits WaitAsync.
|
||||
/// </summary>
|
||||
public sealed class QueueWaker : IQueueWaker
|
||||
{
|
||||
private readonly SemaphoreSlim _signal = new(0, 1);
|
||||
|
||||
public void Wake()
|
||||
{
|
||||
try { _signal.Release(); }
|
||||
catch (SemaphoreFullException) { /* already signalled */ }
|
||||
}
|
||||
|
||||
public Task WaitAsync(CancellationToken ct) => _signal.WaitAsync(ct);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class TaskRunner
|
||||
private readonly ClaudeArgsBuilder _argsBuilder;
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly ILogger<TaskRunner> _logger;
|
||||
private readonly PlanningChainCoordinator _chain;
|
||||
private readonly ITaskStateService _state;
|
||||
|
||||
public TaskRunner(
|
||||
IClaudeProcess claude,
|
||||
@@ -28,7 +28,7 @@ public sealed class TaskRunner
|
||||
ClaudeArgsBuilder argsBuilder,
|
||||
WorkerConfig cfg,
|
||||
ILogger<TaskRunner> logger,
|
||||
PlanningChainCoordinator chain)
|
||||
ITaskStateService state)
|
||||
{
|
||||
_claude = claude;
|
||||
_dbFactory = dbFactory;
|
||||
@@ -37,7 +37,7 @@ public sealed class TaskRunner
|
||||
_argsBuilder = argsBuilder;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
_chain = chain;
|
||||
_state = state;
|
||||
}
|
||||
|
||||
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
|
||||
@@ -91,11 +91,7 @@ public sealed class TaskRunner
|
||||
var resolvedConfig = await ResolveConfigAsync(task, listConfig, null, ct);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.MarkRunningAsync(task.Id, now, ct);
|
||||
}
|
||||
await _state.StartRunningAsync(task.Id, now, ct);
|
||||
await _broadcaster.TaskStarted(slot, task.Id, now);
|
||||
|
||||
// Build prompt.
|
||||
@@ -202,11 +198,7 @@ public sealed class TaskRunner
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.MarkRunningAsync(taskId, now, ct);
|
||||
}
|
||||
await _state.StartRunningAsync(taskId, now, ct);
|
||||
await _broadcaster.TaskStarted(slot, taskId, now);
|
||||
|
||||
var nextRunNumber = lastRun.RunNumber + 1;
|
||||
@@ -332,34 +324,11 @@ public sealed class TaskRunner
|
||||
// is never left as 'running' because of a cancel that arrived
|
||||
// after the Claude run already succeeded.
|
||||
var finishedAt = DateTime.UtcNow;
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
||||
if (task.ParentTaskId is not null)
|
||||
await taskRepo.TryCompleteParentAsync(task.ParentTaskId, CancellationToken.None);
|
||||
}
|
||||
await _state.CompleteAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
||||
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
||||
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
|
||||
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
|
||||
|
||||
// Sequential planning chain: if this task has a parent, flip the next
|
||||
// Waiting sibling to Queued so the queue pickup loop dispatches it next.
|
||||
if (task.ParentTaskId is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var advanced = await _chain.OnChildFinishedAsync(
|
||||
task.Id, TaskStatus.Done, CancellationToken.None);
|
||||
if (advanced is not null)
|
||||
await _broadcaster.TaskUpdated(advanced);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "PlanningChain advance failed for {TaskId}", task.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleFailure(string taskId, string taskTitle, string slot, RunResult result)
|
||||
@@ -367,12 +336,7 @@ public sealed class TaskRunner
|
||||
// Intentionally does not accept a CancellationToken: this is the
|
||||
// terminal write for a failed task and must always be persisted.
|
||||
var finishedAt = DateTime.UtcNow;
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
|
||||
var justFailed = await taskRepo.GetByIdAsync(taskId, CancellationToken.None);
|
||||
if (justFailed?.ParentTaskId is not null)
|
||||
await taskRepo.TryCompleteParentAsync(justFailed.ParentTaskId, CancellationToken.None);
|
||||
await _state.FailAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
|
||||
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
|
||||
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
|
||||
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
|
||||
@@ -384,15 +348,9 @@ public sealed class TaskRunner
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
// Terminal write — never cancel.
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
|
||||
var justFailed = await taskRepo.GetByIdAsync(taskId, CancellationToken.None);
|
||||
if (justFailed?.ParentTaskId is not null)
|
||||
await taskRepo.TryCompleteParentAsync(justFailed.ParentTaskId, CancellationToken.None);
|
||||
await _state.FailAsync(taskId, now, error, CancellationToken.None);
|
||||
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
|
||||
await _broadcaster.TaskFinished(slot, taskId, "failed", now);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Services;
|
||||
|
||||
public sealed class QueueSlotState
|
||||
{
|
||||
public required string TaskId { get; init; }
|
||||
public required DateTime StartedAt { get; init; }
|
||||
public required CancellationTokenSource Cts { get; init; }
|
||||
}
|
||||
|
||||
public sealed class QueueService : BackgroundService
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly TaskRunner _runner;
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly ILogger<QueueService> _logger;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private volatile QueueSlotState? _queueSlot;
|
||||
private volatile QueueSlotState? _overrideSlot;
|
||||
|
||||
private readonly SemaphoreSlim _wakeSignal = new(0, 1);
|
||||
|
||||
public QueueService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
TaskRunner runner,
|
||||
WorkerConfig cfg,
|
||||
ILogger<QueueService> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_runner = runner;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public IReadOnlyList<(string slot, string taskId, DateTime startedAt)> GetActive()
|
||||
{
|
||||
var list = new List<(string, string, DateTime)>();
|
||||
var q = _queueSlot;
|
||||
if (q is not null) list.Add(("queue", q.TaskId, q.StartedAt));
|
||||
var o = _overrideSlot;
|
||||
if (o is not null) list.Add(("override", o.TaskId, o.StartedAt));
|
||||
return list;
|
||||
}
|
||||
|
||||
public void WakeQueue()
|
||||
{
|
||||
// Release if not already signalled.
|
||||
try { _wakeSignal.Release(); }
|
||||
catch (SemaphoreFullException) { /* already signalled */ }
|
||||
}
|
||||
|
||||
public async Task RunNow(string taskId)
|
||||
{
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var taskRepo = new TaskRepository(context);
|
||||
var exists = await taskRepo.GetByIdAsync(taskId);
|
||||
if (exists is null)
|
||||
throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_queueSlot?.TaskId == taskId)
|
||||
throw new InvalidOperationException("task is already running in queue slot");
|
||||
if (_overrideSlot is not null)
|
||||
throw new InvalidOperationException("override slot busy");
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
|
||||
|
||||
_ = RunInSlotAsync(taskId, "override", cts.Token).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId}", taskId);
|
||||
lock (_lock) { _overrideSlot = null; }
|
||||
cts.Dispose();
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> ContinueTask(string taskId, string followUpPrompt)
|
||||
{
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var taskRepo = new TaskRepository(context);
|
||||
var task = await taskRepo.GetByIdAsync(taskId)
|
||||
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||
|
||||
if (task.Status == Data.Models.TaskStatus.Running)
|
||||
throw new InvalidOperationException("task is already running");
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_queueSlot?.TaskId == taskId)
|
||||
throw new InvalidOperationException("task is already running in queue slot");
|
||||
if (_overrideSlot is not null)
|
||||
throw new InvalidOperationException("override slot busy");
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
|
||||
|
||||
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "RunContinueInSlotAsync failed for task {TaskId}", taskId);
|
||||
lock (_lock) { _overrideSlot = null; }
|
||||
cts.Dispose();
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public bool CancelTask(string taskId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_queueSlot is not null && _queueSlot.TaskId == taskId)
|
||||
{
|
||||
_queueSlot.Cts.Cancel();
|
||||
return true;
|
||||
}
|
||||
if (_overrideSlot is not null && _overrideSlot.TaskId == taskId)
|
||||
{
|
||||
_overrideSlot.Cts.Cancel();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("QueueService started");
|
||||
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_cfg.QueueBackstopIntervalMs));
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Wait for wake signal or backstop timer.
|
||||
var wakeTask = _wakeSignal.WaitAsync(stoppingToken);
|
||||
var timerTask = timer.WaitForNextTickAsync(stoppingToken).AsTask();
|
||||
|
||||
await Task.WhenAny(wakeTask, timerTask);
|
||||
|
||||
// Drain wake signal if it fired.
|
||||
if (wakeTask.IsCompletedSuccessfully)
|
||||
{
|
||||
// Good — signal consumed.
|
||||
}
|
||||
|
||||
if (_queueSlot is not null) continue;
|
||||
|
||||
TaskEntity? task;
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var taskRepo = new TaskRepository(context);
|
||||
task = await taskRepo.GetNextQueuedAgentTaskAsync(DateTime.UtcNow, stoppingToken);
|
||||
}
|
||||
if (task is null) continue;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_queueSlot is not null) continue;
|
||||
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
_queueSlot = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts };
|
||||
|
||||
_ = RunInSlotAsync(task.Id, "queue", cts.Token).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id);
|
||||
lock (_lock) { _queueSlot = null; }
|
||||
cts.Dispose();
|
||||
WakeQueue(); // Check for next task immediately.
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "QueueService loop error");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("QueueService stopping");
|
||||
}
|
||||
|
||||
private async Task RunInSlotAsync(string taskId, string slot, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting task {TaskId} in {Slot} slot", taskId, slot);
|
||||
|
||||
TaskEntity task;
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var taskRepo = new TaskRepository(context);
|
||||
task = await taskRepo.GetByIdAsync(taskId, ct)
|
||||
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||
}
|
||||
|
||||
await _runner.RunAsync(task, slot, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Slot runner error for task {TaskId}", taskId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunContinueInSlotAsync(string taskId, string followUpPrompt, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Continuing task {TaskId} in override slot", taskId);
|
||||
await _runner.ContinueAsync(taskId, followUpPrompt, "override", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Continue runner error for task {TaskId}", taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/ClaudeDo.Worker/State/ITaskStateService.cs
Normal file
21
src/ClaudeDo.Worker/State/ITaskStateService.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace ClaudeDo.Worker.State;
|
||||
|
||||
public interface ITaskStateService
|
||||
{
|
||||
Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct);
|
||||
Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct);
|
||||
Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
|
||||
Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
|
||||
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
|
||||
Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
|
||||
|
||||
Task<TransitionResult> ForceSetStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status, CancellationToken ct);
|
||||
|
||||
Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct);
|
||||
Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct);
|
||||
|
||||
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
|
||||
Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
|
||||
|
||||
Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct);
|
||||
}
|
||||
271
src/ClaudeDo.Worker/State/TaskStateService.cs
Normal file
271
src/ClaudeDo.Worker/State/TaskStateService.cs
Normal file
@@ -0,0 +1,271 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.State;
|
||||
|
||||
public sealed class TaskStateService : ITaskStateService
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly IQueueWaker _waker;
|
||||
private readonly PlanningChainCoordinator _chain;
|
||||
private readonly ILogger<TaskStateService> _logger;
|
||||
|
||||
public TaskStateService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
HubBroadcaster broadcaster,
|
||||
IQueueWaker waker,
|
||||
PlanningChainCoordinator chain,
|
||||
ILogger<TaskStateService> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_broadcaster = broadcaster;
|
||||
_waker = waker;
|
||||
_chain = chain;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Queued), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not found or already running.");
|
||||
|
||||
_waker.Wake();
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Running)
|
||||
.SetProperty(t => t.StartedAt, startedAt), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task already running or not found.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct)
|
||||
{
|
||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||
{
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status == TaskStatus.Running)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Done)
|
||||
.SetProperty(t => t.FinishedAt, finishedAt)
|
||||
.SetProperty(t => t.Result, result), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not running; cannot complete.");
|
||||
}
|
||||
|
||||
await OnChildTerminalAsync(taskId, TaskStatus.Done);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct)
|
||||
{
|
||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||
{
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status != TaskStatus.Done)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Failed)
|
||||
.SetProperty(t => t.FinishedAt, finishedAt)
|
||||
.SetProperty(t => t.Result, error), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task already done; cannot fail.");
|
||||
}
|
||||
|
||||
await OnChildTerminalAsync(taskId, TaskStatus.Failed);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct)
|
||||
{
|
||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||
{
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId &&
|
||||
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued))
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Cancelled)
|
||||
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not in cancellable state.");
|
||||
}
|
||||
|
||||
await OnChildTerminalAsync(taskId, TaskStatus.Cancelled);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Idle)
|
||||
.SetProperty(t => t.StartedAt, (DateTime?)null)
|
||||
.SetProperty(t => t.FinishedAt, (DateTime?)null)
|
||||
.SetProperty(t => t.Result, (string?)null), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task is running; cannot reset.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
// Unconditional status write — bypasses transition rules. Used by the UI's
|
||||
// "set status freely" affordance; intentionally no guards (caller may strand
|
||||
// the runner if used while a task is executing).
|
||||
public async Task<TransitionResult> ForceSetStatusAsync(string taskId, TaskStatus status, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, status), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not found.");
|
||||
|
||||
if (status == TaskStatus.Queued) _waker.Wake();
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == parentId
|
||||
&& t.Status == TaskStatus.Idle
|
||||
&& t.PlanningPhase == PlanningPhase.None)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.PlanningPhase, PlanningPhase.Active), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not in plannable state.");
|
||||
|
||||
await _broadcaster.TaskUpdated(parentId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == parentId && t.PlanningPhase == PlanningPhase.Active)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized)
|
||||
.SetProperty(t => t.PlanningFinalizedAt, DateTime.UtcNow)
|
||||
.SetProperty(t => t.PlanningSessionToken, (string?)null), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "No active planning session.");
|
||||
|
||||
await _broadcaster.TaskUpdated(parentId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.BlockedByTaskId, predecessorTaskId), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not found.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.BlockedByTaskId, (string?)null), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not found.");
|
||||
|
||||
_waker.Wake();
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
var resultText = "[stale] " + reason;
|
||||
var now = DateTime.UtcNow;
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
return await ctx.Tasks
|
||||
.Where(t => t.Status == TaskStatus.Running)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Failed)
|
||||
.SetProperty(t => t.FinishedAt, now)
|
||||
.SetProperty(t => t.Result, resultText), ct);
|
||||
}
|
||||
|
||||
private async Task OnChildTerminalAsync(string taskId, TaskStatus finalStatus)
|
||||
{
|
||||
// Terminal child writes are best-effort and use CancellationToken.None so the
|
||||
// task lifecycle is never left partially completed because a caller cancelled.
|
||||
string? parentId;
|
||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(CancellationToken.None))
|
||||
{
|
||||
parentId = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Id == taskId)
|
||||
.Select(t => t.ParentTaskId)
|
||||
.FirstOrDefaultAsync(CancellationToken.None);
|
||||
}
|
||||
if (parentId is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _chain.OnChildFinishedAsync(taskId, finalStatus, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "PlanningChain advance failed for {TaskId}", taskId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(CancellationToken.None);
|
||||
await new TaskRepository(ctx).TryCompleteParentAsync(parentId, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "TryCompleteParent failed for {ParentId}", parentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/ClaudeDo.Worker/State/TransitionResult.cs
Normal file
3
src/ClaudeDo.Worker/State/TransitionResult.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace ClaudeDo.Worker.State;
|
||||
|
||||
public sealed record TransitionResult(bool Ok, string? Reason);
|
||||
@@ -3,7 +3,7 @@ using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Services;
|
||||
namespace ClaudeDo.Worker.Worktrees;
|
||||
|
||||
public sealed class WorktreeMaintenanceService
|
||||
{
|
||||
@@ -6,38 +6,38 @@ public class StreamLineFormatterTests
|
||||
{
|
||||
private readonly StreamLineFormatter _formatter = new();
|
||||
|
||||
// --- Text deltas ---
|
||||
// --- Assistant text blocks ---
|
||||
|
||||
[Fact]
|
||||
public void FormatLine_TextDelta_ReturnsTextContent()
|
||||
public void FormatLine_AssistantTextBlock_ReturnsTextContent()
|
||||
{
|
||||
var line = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello world"}}}""";
|
||||
Assert.Equal("Hello world", _formatter.FormatLine(line));
|
||||
var line = """{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}""";
|
||||
Assert.Equal("Hello world\n", _formatter.FormatLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatLine_ConsecutiveTextDeltas_ReturnEachDelta()
|
||||
public void FormatLine_AssistantConsecutiveTextBlocks_ReturnEachAppended()
|
||||
{
|
||||
var line1 = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello "}}}""";
|
||||
var line2 = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}}""";
|
||||
Assert.Equal("Hello ", _formatter.FormatLine(line1));
|
||||
Assert.Equal("world", _formatter.FormatLine(line2));
|
||||
var line1 = """{"type":"assistant","message":{"content":[{"type":"text","text":"Hello "}]}}""";
|
||||
var line2 = """{"type":"assistant","message":{"content":[{"type":"text","text":"world"}]}}""";
|
||||
Assert.Equal("Hello \n", _formatter.FormatLine(line1));
|
||||
Assert.Equal("world\n", _formatter.FormatLine(line2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatLine_ContentBlockStop_ReturnsNewline()
|
||||
public void FormatLine_AssistantThinkingBlock_IsFiltered()
|
||||
{
|
||||
var line = """{"type":"stream_event","event":{"type":"content_block_stop","index":0}}""";
|
||||
Assert.Equal("\n", _formatter.FormatLine(line));
|
||||
var line = """{"type":"assistant","message":{"content":[{"type":"thinking","text":"hidden"}]}}""";
|
||||
Assert.Null(_formatter.FormatLine(line));
|
||||
}
|
||||
|
||||
// --- Tool use, result, system, fallback ---
|
||||
|
||||
[Fact]
|
||||
public void FormatLine_ToolUseStart_ReturnsToolNameLine()
|
||||
public void FormatLine_AssistantToolUseBlock_ReturnsToolNameLine()
|
||||
{
|
||||
var line = """{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"x","name":"bash"}}}""";
|
||||
Assert.Equal("\n[Tool: bash]\n", _formatter.FormatLine(line));
|
||||
var line = """{"type":"assistant","message":{"content":[{"type":"tool_use","id":"x","name":"Bash","input":{"command":"ls"}}]}}""";
|
||||
Assert.Equal("[Bash] $ ls\n", _formatter.FormatLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -58,13 +58,13 @@ public class StreamLineFormatterTests
|
||||
public void FormatLine_ApiRetry_ReturnsRetryNotice()
|
||||
{
|
||||
var line = """{"type":"system","subtype":"api_retry"}""";
|
||||
Assert.Equal("\n[Retrying API call...]\n", _formatter.FormatLine(line));
|
||||
Assert.Equal("[Retrying API call...]\n", _formatter.FormatLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatLine_SystemNonRetry_ReturnsNull()
|
||||
public void FormatLine_SystemUnknownSubtype_ReturnsNull()
|
||||
{
|
||||
var line = """{"type":"system","subtype":"init"}""";
|
||||
var line = """{"type":"system","subtype":"some_unknown_subtype"}""";
|
||||
Assert.Null(_formatter.FormatLine(line));
|
||||
}
|
||||
|
||||
@@ -98,8 +98,8 @@ public class StreamLineFormatterTests
|
||||
{
|
||||
var lines = new[]
|
||||
{
|
||||
"""{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}}""",
|
||||
"""{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"x","name":"bash"}}}""",
|
||||
"""{"type":"assistant","message":{"content":[{"type":"text","text":"Hello"}]}}""",
|
||||
"""{"type":"assistant","message":{"content":[{"type":"tool_use","id":"x","name":"Bash","input":{"command":"ls"}}]}}""",
|
||||
"""{"type":"result","result":"Done."}""",
|
||||
};
|
||||
var file = Path.GetTempFileName();
|
||||
@@ -108,7 +108,7 @@ public class StreamLineFormatterTests
|
||||
File.WriteAllLines(file, lines);
|
||||
var result = _formatter.FormatFile(file);
|
||||
Assert.Contains("Hello", result);
|
||||
Assert.Contains("[Tool: bash]", result);
|
||||
Assert.Contains("[Bash]", result);
|
||||
Assert.Contains("Done.", result);
|
||||
}
|
||||
finally
|
||||
@@ -121,7 +121,7 @@ public class StreamLineFormatterTests
|
||||
public void FormatFile_TrimsLargeContent()
|
||||
{
|
||||
var chunk = new string('x', 1000);
|
||||
var line = "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"" + chunk + "\"}}}";
|
||||
var line = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"" + chunk + "\"}]}}";
|
||||
var lines = Enumerable.Repeat(line, 65).ToArray();
|
||||
var file = Path.GetTempFileName();
|
||||
try
|
||||
|
||||
@@ -37,6 +37,9 @@ public class ConflictResolutionViewModelTests
|
||||
public Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
||||
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
||||
public Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status) => Task.CompletedTask;
|
||||
public Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames) => Task.CompletedTask;
|
||||
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
||||
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
@@ -48,6 +51,8 @@ public class ConflictResolutionViewModelTests
|
||||
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
|
||||
Task.FromResult<CombinedDiffResultDto?>(null);
|
||||
public Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) => Task.CompletedTask;
|
||||
public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
|
||||
public Task ContinuePlanningMergeAsync(string planningTaskId)
|
||||
{
|
||||
|
||||
@@ -67,6 +67,9 @@ public class DetailsIslandPlanningTests : IDisposable
|
||||
public Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
||||
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
||||
public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
|
||||
public Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames) => Task.CompletedTask;
|
||||
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
||||
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
@@ -80,6 +83,8 @@ public class DetailsIslandPlanningTests : IDisposable
|
||||
public Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) => Task.CompletedTask;
|
||||
public Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||
public Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||
public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NullServiceProvider : IServiceProvider
|
||||
@@ -175,7 +180,8 @@ public class DetailsIslandPlanningTests : IDisposable
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = parentId, ListId = listId, Title = "Parent",
|
||||
Status = TaskStatus.Planning, CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.Idle, PlanningPhase = PlanningPhase.Active,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
@@ -199,7 +205,8 @@ public class DetailsIslandPlanningTests : IDisposable
|
||||
|
||||
// Bind triggers BindAsync → LoadPlanningChildrenAsync → GetMergeTargetsAsync
|
||||
var parentRow = new TaskRowViewModel { Id = parentId };
|
||||
parentRow.Status = TaskStatus.Planning;
|
||||
parentRow.Status = TaskStatus.Idle;
|
||||
parentRow.PlanningPhase = PlanningPhase.Active;
|
||||
vm.Bind(parentRow);
|
||||
|
||||
// Wait for the background load to settle
|
||||
|
||||
@@ -34,6 +34,9 @@ public class PlanningDiffViewModelTests
|
||||
public Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
||||
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
||||
public Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status) => Task.CompletedTask;
|
||||
public Task SetTaskTagsAsync(string taskId, IEnumerable<string> tagNames) => Task.CompletedTask;
|
||||
public Task<List<string>> GetAllTagsAsync() => Task.FromResult(new List<string>());
|
||||
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
@@ -47,6 +50,8 @@ public class PlanningDiffViewModelTests
|
||||
public Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) => Task.CompletedTask;
|
||||
public Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||
public Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||
public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class PrimeClaudeTabViewModelTests
|
||||
{
|
||||
private sealed class FakeApi : IPrimeScheduleApi
|
||||
{
|
||||
public List<PrimeScheduleDto> Stored { get; } = new();
|
||||
public List<PrimeScheduleDto> Upserts { get; } = new();
|
||||
public List<Guid> Deletes { get; } = new();
|
||||
public Task<List<PrimeScheduleDto>> ListAsync() => Task.FromResult(Stored.ToList());
|
||||
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto)
|
||||
{
|
||||
Upserts.Add(dto);
|
||||
return Task.FromResult<PrimeScheduleDto?>(dto);
|
||||
}
|
||||
public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Load_Populates_Rows()
|
||||
{
|
||||
var api = new FakeApi();
|
||||
api.Stored.Add(new PrimeScheduleDto(
|
||||
Guid.NewGuid(), new DateOnly(2026,5,1), new DateOnly(2026,5,31),
|
||||
new TimeSpan(7,0,0), true, true, null, null));
|
||||
var vm = new PrimeClaudeTabViewModel(api);
|
||||
await vm.LoadAsync();
|
||||
Assert.Single(vm.Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSchedule_Appends_Row_With_Defaults()
|
||||
{
|
||||
var vm = new PrimeClaudeTabViewModel(new FakeApi());
|
||||
vm.AddScheduleCommand.Execute(null);
|
||||
Assert.Single(vm.Rows);
|
||||
Assert.True(vm.Rows[0].Enabled);
|
||||
Assert.True(vm.Rows[0].WorkdaysOnly);
|
||||
Assert.Equal(new TimeSpan(7,0,0), vm.Rows[0].TimeOfDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_Diffs_New_And_Removed_Rows()
|
||||
{
|
||||
var api = new FakeApi();
|
||||
var keptId = Guid.NewGuid();
|
||||
var deletedId = Guid.NewGuid();
|
||||
api.Stored.Add(new PrimeScheduleDto(keptId, new(2026,5,1), new(2026,5,31), new(7,0,0), true, true, null, null));
|
||||
api.Stored.Add(new PrimeScheduleDto(deletedId, new(2026,5,1), new(2026,5,31), new(8,0,0), true, true, null, null));
|
||||
|
||||
var vm = new PrimeClaudeTabViewModel(api);
|
||||
await vm.LoadAsync();
|
||||
vm.RemoveScheduleCommand.Execute(vm.Rows.Single(r => r.Id == deletedId));
|
||||
vm.AddScheduleCommand.Execute(null);
|
||||
|
||||
await vm.SaveAsync();
|
||||
|
||||
Assert.Contains(deletedId, api.Deletes);
|
||||
Assert.Equal(2, api.Upserts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Reports_StartAfterEnd()
|
||||
{
|
||||
var vm = new PrimeClaudeTabViewModel(new FakeApi());
|
||||
vm.AddScheduleCommand.Execute(null);
|
||||
vm.Rows[0].StartDate = new DateOnly(2026, 6, 1);
|
||||
vm.Rows[0].EndDate = new DateOnly(2026, 5, 1);
|
||||
Assert.NotNull(vm.Validate());
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,8 @@ public class TasksIslandRegroupTests : IDisposable
|
||||
TaskStatus parentStatus,
|
||||
TaskStatus childStatus,
|
||||
string parentId = "p1",
|
||||
string childId = "c1")
|
||||
string childId = "c1",
|
||||
PlanningPhase parentPhase = PlanningPhase.None)
|
||||
{
|
||||
await using var db = NewContext();
|
||||
var list = new ListEntity
|
||||
@@ -67,6 +68,7 @@ public class TasksIslandRegroupTests : IDisposable
|
||||
Title = "Parent",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Status = parentStatus,
|
||||
PlanningPhase = parentPhase,
|
||||
SortOrder = 0,
|
||||
});
|
||||
db.Tasks.Add(new TaskEntity
|
||||
@@ -110,7 +112,7 @@ public class TasksIslandRegroupTests : IDisposable
|
||||
public async Task VirtualQueued_QueuedChildOfPlanningParent_IsNotStandaloneRow()
|
||||
{
|
||||
await SeedPlanningWithChildAsync(
|
||||
parentStatus: TaskStatus.Planning,
|
||||
parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Active,
|
||||
childStatus: TaskStatus.Queued,
|
||||
parentId: "p1",
|
||||
childId: "c1");
|
||||
@@ -126,7 +128,7 @@ public class TasksIslandRegroupTests : IDisposable
|
||||
public async Task VirtualQueued_PlannedParentWithQueuedChild_ParentIsStandaloneRow_ChildIsNot()
|
||||
{
|
||||
await SeedPlanningWithChildAsync(
|
||||
parentStatus: TaskStatus.Planned,
|
||||
parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Finalized,
|
||||
childStatus: TaskStatus.Queued,
|
||||
parentId: "p1",
|
||||
childId: "c1");
|
||||
@@ -142,7 +144,7 @@ public class TasksIslandRegroupTests : IDisposable
|
||||
public async Task VirtualRunning_RunningChildOfPlanningParent_IsNotStandaloneRow()
|
||||
{
|
||||
await SeedPlanningWithChildAsync(
|
||||
parentStatus: TaskStatus.Planning,
|
||||
parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Active,
|
||||
childStatus: TaskStatus.Running,
|
||||
parentId: "p1",
|
||||
childId: "c1");
|
||||
@@ -158,7 +160,7 @@ public class TasksIslandRegroupTests : IDisposable
|
||||
public async Task Done_ChildOfOpenPlanningParent_StaysNestedUnderParent()
|
||||
{
|
||||
await SeedPlanningWithChildAsync(
|
||||
parentStatus: TaskStatus.Planning,
|
||||
parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Active,
|
||||
childStatus: TaskStatus.Done,
|
||||
parentId: "p1",
|
||||
childId: "c1");
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class TasksIslandRemoveFromQueueTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public TasksIslandRemoveFromQueueTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_unq_{Guid.NewGuid():N}.db");
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { File.Delete(_dbPath); } catch { }
|
||||
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeDoDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||
{
|
||||
private readonly Func<ClaudeDoDbContext> _create;
|
||||
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||
}
|
||||
|
||||
private TasksIslandViewModel BuildViewModel() =>
|
||||
new(new TestDbFactory(NewContext), worker: null);
|
||||
|
||||
private async Task SeedParentWithChainAsync(
|
||||
string parentId,
|
||||
PlanningPhase parentPhase,
|
||||
params (string Id, TaskStatus Status, string? BlockedBy)[] children)
|
||||
{
|
||||
await using var db = NewContext();
|
||||
db.Lists.Add(new ListEntity
|
||||
{
|
||||
Id = "list1",
|
||||
Name = "Default",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
db.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = parentId,
|
||||
ListId = "list1",
|
||||
Title = "Parent",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.Idle,
|
||||
PlanningPhase = parentPhase,
|
||||
SortOrder = 0,
|
||||
});
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
{
|
||||
var c = children[i];
|
||||
db.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = c.Id,
|
||||
ListId = "list1",
|
||||
Title = $"Child {i}",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Status = c.Status,
|
||||
ParentTaskId = parentId,
|
||||
BlockedByTaskId = c.BlockedBy,
|
||||
SortOrder = i + 1,
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task LoadAndWaitAsync(TasksIslandViewModel vm)
|
||||
{
|
||||
var list = new ListNavItemViewModel { Id = "user:list1", Kind = ListKind.User, Name = "Default" };
|
||||
vm.LoadForList(list);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(25);
|
||||
if (vm.Items.Count > 0) break;
|
||||
}
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveFromQueue_PlanningPhaseNone_ParentWithQueuedChildren_CascadesUnqueue()
|
||||
{
|
||||
// Mirrors the BoxDataReader scenario: parent has PlanningPhase=None
|
||||
// but a queued chain of children exists under it. Click X on the parent
|
||||
// should clear the chain, even though planning_phase is None.
|
||||
await SeedParentWithChainAsync(
|
||||
"p1",
|
||||
PlanningPhase.None,
|
||||
("c1", TaskStatus.Idle, null),
|
||||
("c2", TaskStatus.Queued, null),
|
||||
("c3", TaskStatus.Queued, "c2"),
|
||||
("c4", TaskStatus.Queued, "c3"));
|
||||
|
||||
var vm = BuildViewModel();
|
||||
await LoadAndWaitAsync(vm);
|
||||
|
||||
var parentRow = vm.Items.First(r => r.Id == "p1");
|
||||
await vm.RemoveFromQueueCommand.ExecuteAsync(parentRow);
|
||||
|
||||
await using var db = NewContext();
|
||||
var kids = await db.Tasks.AsNoTracking()
|
||||
.Where(t => t.ParentTaskId == "p1")
|
||||
.OrderBy(t => t.SortOrder)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Equal(TaskStatus.Idle, kids[0].Status);
|
||||
Assert.All(kids.Where(k => k.Id != "c1"), k =>
|
||||
{
|
||||
Assert.Equal(TaskStatus.Idle, k.Status);
|
||||
Assert.Null(k.BlockedByTaskId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveFromQueue_QueuedTaskWithoutChildren_UnqueuesItself()
|
||||
{
|
||||
// Sanity check: existing single-task unqueue path still works.
|
||||
await using (var db = NewContext())
|
||||
{
|
||||
db.Lists.Add(new ListEntity
|
||||
{
|
||||
Id = "list1",
|
||||
Name = "Default",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
db.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = "solo",
|
||||
ListId = "list1",
|
||||
Title = "Solo",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.Queued,
|
||||
SortOrder = 0,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var vm = BuildViewModel();
|
||||
await LoadAndWaitAsync(vm);
|
||||
|
||||
var row = vm.Items.First(r => r.Id == "solo");
|
||||
await vm.RemoveFromQueueCommand.ExecuteAsync(row);
|
||||
|
||||
await using var verify = NewContext();
|
||||
var t = await verify.Tasks.AsNoTracking().FirstAsync(x => x.Id == "solo");
|
||||
Assert.Equal(TaskStatus.Idle, t.Status);
|
||||
}
|
||||
}
|
||||
298
tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
vendored
Normal file
298
tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
vendored
Normal file
@@ -0,0 +1,298 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using ClaudeDo.Worker.Tests.Services;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
internal sealed class ExternalRecordingHubClients : IHubClients
|
||||
{
|
||||
public ExternalRecordingClientProxy Proxy { get; } = new();
|
||||
public IClientProxy All => Proxy;
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||
public IClientProxy Client(string connectionId) => Proxy;
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
|
||||
public IClientProxy Group(string groupName) => Proxy;
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
|
||||
public IClientProxy User(string userId) => Proxy;
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
|
||||
}
|
||||
|
||||
internal sealed class ExternalRecordingClientProxy : IClientProxy
|
||||
{
|
||||
public List<(string Method, object?[] Args)> Calls { get; } = new();
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Calls.Add((method, args));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ExternalFakeHubContext : IHubContext<WorkerHub>
|
||||
{
|
||||
public ExternalRecordingHubClients RecordingClients { get; } = new();
|
||||
public IHubClients Clients => RecordingClients;
|
||||
public IGroupManager Groups => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public sealed class ExternalMcpServiceTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TagRepository _tags;
|
||||
private readonly ExternalFakeHubContext _hub = new();
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
|
||||
public ExternalMcpServiceTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
_tags = new TagRepository(_ctx);
|
||||
_broadcaster = new HubBroadcaster(_hub);
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private async Task<string> SeedListAsync(string name = "L")
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow });
|
||||
return id;
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Idle)
|
||||
{
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = title,
|
||||
Status = status,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
// QueueService is needed by ExternalMcpService's constructor. For tests that
|
||||
// only exercise UpdateTask / DeleteTask / SetTaskTags / ListTags / ListTags,
|
||||
// we never call its WakeQueue/RunNow/CancelTask paths, so a real QueueService
|
||||
// built with the same approach used in QueueServiceTests is sufficient.
|
||||
private ExternalMcpService BuildSut(QueueService queue) =>
|
||||
new(_tasks, _lists, queue, _broadcaster, _tags,
|
||||
TaskStateServiceBuilder.Build(_db.CreateFactory()).State);
|
||||
|
||||
private QueueService CreateQueue()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_ext_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var cfg = new WorkerConfig
|
||||
{
|
||||
SandboxRoot = Path.Combine(tempDir, "sandbox"),
|
||||
LogRoot = Path.Combine(tempDir, "logs"),
|
||||
QueueBackstopIntervalMs = 50,
|
||||
};
|
||||
var fake = new FakeClaudeProcess();
|
||||
var hubCtx = new FakeHubContext();
|
||||
var broadcaster = new HubBroadcaster(hubCtx);
|
||||
var dbFactory = _db.CreateFactory();
|
||||
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var argsBuilder = new ClaudeArgsBuilder();
|
||||
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
|
||||
NullLogger<TaskRunner>.Instance, TaskStateServiceBuilder.Build(dbFactory).State);
|
||||
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
|
||||
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
|
||||
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
|
||||
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeededListAndTask_AreRetrievable()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
Assert.NotNull(await _tasks.GetByIdAsync(task.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListTags_ReturnsSeededAndCustomTags()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "custom-tag" });
|
||||
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var tags = await sut.ListTags(CancellationToken.None);
|
||||
|
||||
Assert.Contains(tags, t => t.Name == "agent");
|
||||
Assert.Contains(tags, t => t.Name == "custom-tag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTask_WithTags_AttachesTags()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.AddTask(
|
||||
listId, "scope-creep handoff", "desc", "claude-cli",
|
||||
queueImmediately: false,
|
||||
tags: new[] { "agent", "custom" },
|
||||
CancellationToken.None);
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(dto.Id);
|
||||
Assert.Contains(tags, t => t.Name == "agent");
|
||||
Assert.Contains(tags, t => t.Name == "custom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTask_NullTags_BehavesAsBefore()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.AddTask(
|
||||
listId, "no tags", null, "claude-cli",
|
||||
queueImmediately: false, tags: null, CancellationToken.None);
|
||||
|
||||
Assert.Empty(await _tasks.GetTagsAsync(dto.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_PatchesNonNullFieldsOnly()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, "old title");
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("new title", dto.Title);
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal("new title", loaded!.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_TagsReplaceFullSet()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await sut.UpdateTask(task.Id, null, null, null, new[] { "manual" }, CancellationToken.None);
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
||||
Assert.Single(tags);
|
||||
Assert.Equal("manual", tags[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_OnRunning_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_NotFound_Throws()
|
||||
{
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTask_RemovesTaskAndTagJoins()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await sut.DeleteTask(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Null(await _tasks.GetByIdAsync(task.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTask_OnRunning_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.DeleteTask(task.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTask_NotFound_Throws()
|
||||
{
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.DeleteTask("does-not-exist", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTaskTags_ReplacesTagSetAndBroadcasts()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None);
|
||||
|
||||
var tags = await _tasks.GetTagsAsync(task.Id);
|
||||
Assert.Single(tags);
|
||||
Assert.Equal("manual", tags[0].Name);
|
||||
Assert.Contains(_hub.RecordingClients.Proxy.Calls,
|
||||
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == task.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTaskTags_OnRunning_Throws()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user