Compare commits
51 Commits
0b19ea739c
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfc45118e4 | ||
|
|
1856943925 | ||
|
|
ce9fadc0b5 | ||
|
|
25ee623c42 | ||
|
|
41da124a31 | ||
|
|
77100b6b3b | ||
|
|
32daa4a602 | ||
|
|
b41a78ec29 | ||
|
|
9ea60701d2 | ||
|
|
5a592c4be6 | ||
|
|
7196aab31f | ||
|
|
fec2fe2dda | ||
|
|
3afe29d721 | ||
|
|
f3f8af4b11 | ||
|
|
c3493a3a74 | ||
|
|
ac2f1d824e | ||
|
|
53f4e2de0f | ||
|
|
99dc08488b | ||
|
|
26c4e5771b | ||
|
|
1e5b3a6c3e | ||
|
|
59d72635da | ||
|
|
7a88e8a848 | ||
|
|
b84716ff9c | ||
|
|
ce879f6f70 | ||
|
|
2f7f00d4cc | ||
|
|
6d0973c67c | ||
|
|
bb8b3e235a | ||
|
|
6e3947c0b1 | ||
|
|
128fb7d4d2 | ||
|
|
3af8fb9aa0 | ||
|
|
5b15e30b8a | ||
|
|
e5bce07719 | ||
|
|
9c638e72b1 | ||
|
|
c43b06d83d | ||
|
|
d4674cd74e | ||
|
|
e4d958dcf3 | ||
|
|
0f41384fa8 | ||
|
|
50b1589b23 | ||
|
|
1c689a8472 | ||
|
|
4877c11aa2 | ||
|
|
03617ee3cd | ||
|
|
7869c2a979 | ||
|
|
ce79a2d0fe | ||
|
|
09a930e28e | ||
|
|
c1c7862672 | ||
|
|
19f22d2d97 | ||
|
|
12668f684f | ||
|
|
967e0cd319 | ||
|
|
2223839595 | ||
|
|
7d61d38a34 | ||
|
|
e55367af67 |
16
.gitattributes
vendored
Normal file
16
.gitattributes
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.sln text eol=crlf
|
||||
*.slnx text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.bat text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.zip binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -35,18 +35,23 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
|
||||
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
|
||||
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
|
||||
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
|
||||
- Task status flow: Manual | Queued -> Running -> Done | Failed
|
||||
- Task status flow: Idle | Queued -> Running -> Done | Failed | Cancelled
|
||||
- Worktree state flow: Active -> Merged | Discarded | Kept
|
||||
- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup
|
||||
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
|
||||
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
|
||||
- Small single-consumer helper types live in their consumer's file, not standalone files
|
||||
- Commit messages use conventional format: `{commitType}(slug): title`
|
||||
- Views use compiled bindings (`x:DataType`)
|
||||
- ViewModels use `[ObservableProperty]` and `[RelayCommand]` source generators
|
||||
|
||||
## Building & Testing
|
||||
|
||||
`dotnet build ClaudeDo.slnx` requires .NET 9; on .NET 8 build individual projects instead.
|
||||
|
||||
```bash
|
||||
dotnet build ClaudeDo.slnx
|
||||
dotnet test tests/ClaudeDo.Worker.Tests
|
||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj # pulls in Ui + Data
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
dotnet test tests/ClaudeDo.Worker.Tests # also: Data.Tests, Ui.Tests, Installer.Tests, Releases.Tests
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
29
docs/open.md
29
docs/open.md
@@ -65,9 +65,9 @@ Bedingt durch Slice "Planning B/C". Ablauf identisch zur alten open.md:
|
||||
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.
|
||||
- ✅ `Border.badge.planned` (blau) wird jetzt bei `Finalized` angewendet — `TaskRowView` nutzt `Classes.planning`/`Classes.planned` gebunden an `IsPlanActive`/`IsPlanFinalized`; der Child-„PLANNED"-Badge nutzt direkt `planned`.
|
||||
- ✅ Tote `Instance`-Statics auf `BoolToItalicConverter` und `BoolToDraftOpacityConverter` entfernt (Registrierung läuft über das Resource-Dictionary in `App.axaml`).
|
||||
- ✅ `Ui.Tests` IWorkerClient-Fakes auf gemeinsame Basis `StubWorkerClient` rebased — kein Constructor-Drift mehr; die drei Fakes überschreiben nur ihre relevanten Member.
|
||||
|
||||
### 1.2 Prime Claude — Manual Verification
|
||||
|
||||
@@ -129,11 +129,11 @@ Voraussetzung: funktionierendes Gitea-Release unter `git.kuns.dev/releases/Claud
|
||||
### 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.8 (NEU) Planning-Phase Badge-Farbe für `Finalized` ✅
|
||||
`Finalized` zeigt jetzt den blauen `planned`-Badge (Class-Binding in `TaskRowView`).
|
||||
|
||||
### 2.9 (NEU) Tote Converter-Statics entfernen ⬜
|
||||
`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance` — siehe §1.1.
|
||||
### 2.9 (NEU) Tote Converter-Statics entfernen ✅
|
||||
`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance` entfernt.
|
||||
|
||||
---
|
||||
|
||||
@@ -161,8 +161,11 @@ Siehe §1.1 — `planning`-Klasse bleibt amber, blauer `planned`-Style nicht ang
|
||||
|
||||
## 4. Service-Deployment
|
||||
|
||||
### 4.1 Windows-Service-Hosting ✅
|
||||
- `Program.cs` ruft `builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker")`.
|
||||
### 4.1 Worker-Autostart als Per-User-Task ✅ (ersetzt Windows-Service)
|
||||
- Der Worker läuft **nicht mehr als Windows-Service** (LocalSystem konnte die Claude-CLI-Auth des Users nicht sehen). Stattdessen: per-user **Logon-Scheduled-Task** „ClaudeDoWorker" (`schtasks /Create /XML`), läuft als angemeldeter User, versteckt, mit Restart-on-Failure.
|
||||
- Worker ist `WinExe` (kein Konsolenfenster) + Serilog-File-Sink (`~/.todo-app/logs/worker-*.log`) + Single-Instance-Mutex.
|
||||
- Installer migriert beim Update den alten Service automatisch weg (`sc stop`/`delete`) und registriert die Task; Uninstall entfernt Task + Worker-Prozess. App startet/neustartet den Worker als Prozess und sorgt beim Start dafür, dass er läuft.
|
||||
- Implementiert 2026-05-29, getestet (Build + Unit-Tests grün), **manuelle E2E-Verifikation am Gerät ausstehend** (Update von 1.0.2-alpha → Task, Logoff/Logon-Autostart, Uninstall).
|
||||
|
||||
### 4.2 Pfad-Auflösung absolut ✅
|
||||
- `WorkerConfig.Load` expandiert `~`/`%USERPROFILE%` für alle Pfad-Felder.
|
||||
@@ -220,13 +223,13 @@ Siehe §1.1 — `planning`-Klasse bleibt amber, blauer `planned`-Style nicht ang
|
||||
|
||||
| Stelle | Issue | Status |
|
||||
|---|---|---|
|
||||
| `WorkerHub.GetActive` returnt `IReadOnlyList<object>` mit anonymen Typen | Sollte expliziter `ActiveTaskDto` sein, den Worker UND UI teilen | ⬜ |
|
||||
| `WorkerHub.GetActive` returnt `IReadOnlyList<object>` mit anonymen Typen | Sollte expliziter `ActiveTaskDto` sein, den Worker UND UI teilen | ✅ (gibt bereits `IReadOnlyList<ActiveTaskDto>` zurück) |
|
||||
| `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 | ⬜ |
|
||||
| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer | ✅ (`.gitattributes` angelegt) |
|
||||
| Tote Converter-Instances (`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance`) | Per Resource-Dictionary registriert, Statics ungenutzt | ✅ (entfernt) |
|
||||
| 1 unausgeführter `// TODO` in `DetailsIslandViewModel` (`SendPromptAsync` ohne Hub-Methode) | Entweder Hub-Methode bauen oder TODO entfernen | ✅ (im Main-Code nicht mehr vorhanden) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
834
docs/superpowers/plans/2026-05-29-repo-import-list-helper.md
Normal file
834
docs/superpowers/plans/2026-05-29-repo-import-list-helper.md
Normal file
@@ -0,0 +1,834 @@
|
||||
# Repo Import List Helper Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a helper that scans parent folders for git repos and bulk-creates lists (with `WorkingDir` pre-filled) for the repos the user ticks.
|
||||
|
||||
**Architecture:** A pure `RepoScanner` finds git repos under a parent folder. A `RepoImportModalViewModel` loads existing lists' working dirs, merges scanned candidates into a checklist (marking already-added repos), and creates `ListEntity` rows for ticked-new repos via `ListRepository`. `RepoImportModalView` hosts the checklist and a folder picker. Two entry points open the modal: a Help-menu item (handled by `IslandsShellViewModel`) and a folder button in the Lists island (handled by `ListsIslandViewModel`). Each entry point reloads the Lists island after the modal closes.
|
||||
|
||||
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, EF Core (SQLite), xUnit.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create:**
|
||||
- `src/ClaudeDo.Ui/Services/RepoScanner.cs` — pure filesystem scan; `RepoCandidate` record + `RepoScanner.Scan`.
|
||||
- `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs` — one checklist row.
|
||||
- `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs` — modal VM (load, merge, create) + static `BuildCandidates`.
|
||||
- `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml` (+ `.axaml.cs`) — modal window + folder picker.
|
||||
- `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs` — scanner unit tests.
|
||||
- `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs` — merge/dedupe/already-added unit tests.
|
||||
|
||||
**Modify:**
|
||||
- `src/ClaudeDo.App/Program.cs` — register `RepoImportModalViewModel` (transient) + a `Func<RepoImportModalViewModel>`; pass the Func into `IslandsShellViewModel`.
|
||||
- `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs` — `ShowRepoImportModal` Func + `OpenRepoImportCommand`.
|
||||
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml` — folder button beside `+ New list`.
|
||||
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs` — wire `ShowRepoImportModal`.
|
||||
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — `ShowRepoImportModal` Func + `OpenRepoImportCommand`; inject `Func<RepoImportModalViewModel>`.
|
||||
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` — Help-menu item `Add repos as lists…`.
|
||||
- `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowRepoImportModal`.
|
||||
- `src/ClaudeDo.Ui/CLAUDE.md` — document the new modal + entry points.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: RepoScanner
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/Services/RepoScanner.cs`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
public sealed class RepoScannerTests : IDisposable
|
||||
{
|
||||
private readonly string _root =
|
||||
Path.Combine(Path.GetTempPath(), "repo-scan-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public RepoScannerTests() => Directory.CreateDirectory(_root);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private string MakeDir(string name)
|
||||
{
|
||||
var p = Path.Combine(_root, name);
|
||||
Directory.CreateDirectory(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scan_ReturnsSubfoldersWithGitDirectory()
|
||||
{
|
||||
var repo = MakeDir("repo-a");
|
||||
Directory.CreateDirectory(Path.Combine(repo, ".git"));
|
||||
|
||||
var result = RepoScanner.Scan(_root);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal("repo-a", result[0].Name);
|
||||
Assert.Equal(repo, result[0].FullPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scan_TreatsDotGitFileAsRepo()
|
||||
{
|
||||
var repo = MakeDir("worktree-repo");
|
||||
File.WriteAllText(Path.Combine(repo, ".git"), "gitdir: ../somewhere");
|
||||
|
||||
var result = RepoScanner.Scan(_root);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal("worktree-repo", result[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scan_IgnoresPlainFolders()
|
||||
{
|
||||
MakeDir("not-a-repo");
|
||||
|
||||
var result = RepoScanner.Scan(_root);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scan_IsNotRecursive()
|
||||
{
|
||||
var nested = MakeDir(Path.Combine("outer", "inner"));
|
||||
Directory.CreateDirectory(Path.Combine(nested, ".git"));
|
||||
// outer itself has no .git
|
||||
|
||||
var result = RepoScanner.Scan(_root);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scan_ReturnsEmptyForMissingFolder()
|
||||
{
|
||||
var result = RepoScanner.Scan(Path.Combine(_root, "does-not-exist"));
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoScannerTests`
|
||||
Expected: FAIL — `RepoScanner` / `RepoCandidate` do not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Implement RepoScanner**
|
||||
|
||||
Create `src/ClaudeDo.Ui/Services/RepoScanner.cs`:
|
||||
|
||||
```csharp
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed record RepoCandidate(string Name, string FullPath);
|
||||
|
||||
public static class RepoScanner
|
||||
{
|
||||
public static IReadOnlyList<RepoCandidate> Scan(string parentFolder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parentFolder) || !Directory.Exists(parentFolder))
|
||||
return Array.Empty<RepoCandidate>();
|
||||
|
||||
var result = new List<RepoCandidate>();
|
||||
IEnumerable<string> subdirs;
|
||||
try { subdirs = Directory.EnumerateDirectories(parentFolder); }
|
||||
catch { return Array.Empty<RepoCandidate>(); }
|
||||
|
||||
foreach (var dir in subdirs)
|
||||
{
|
||||
var gitPath = Path.Combine(dir, ".git");
|
||||
if (Directory.Exists(gitPath) || File.Exists(gitPath))
|
||||
result.Add(new RepoCandidate(Path.GetFileName(dir), dir));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoScannerTests`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Services/RepoScanner.cs tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs
|
||||
git commit -m "feat(ui): add RepoScanner for git repo discovery"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: RepoImportItemViewModel
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs`
|
||||
|
||||
No dedicated test (trivial display VM; covered indirectly by Task 3).
|
||||
|
||||
- [ ] **Step 1: Implement the item VM**
|
||||
|
||||
Create `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs`:
|
||||
|
||||
```csharp
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class RepoImportItemViewModel : ViewModelBase
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public string FullPath { get; init; } = "";
|
||||
|
||||
// True when a list already points at this path. Such rows are shown ticked + disabled.
|
||||
public bool AlreadyAdded { get; init; }
|
||||
public bool CanToggle => !AlreadyAdded;
|
||||
|
||||
[ObservableProperty] private bool _isChecked;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs
|
||||
git commit -m "feat(ui): add RepoImportItemViewModel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: RepoImportModalViewModel
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs`
|
||||
|
||||
The pure `BuildCandidates` static method is the tested seam (dedupe + already-added marking). `LoadAsync`/`CreateAsync` touch the DB and are verified manually.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
public sealed class RepoImportCandidatesTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCandidates_NewRepo_IsCheckedAndNotAlreadyAdded()
|
||||
{
|
||||
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
|
||||
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
|
||||
|
||||
Assert.Single(items);
|
||||
Assert.True(items[0].IsChecked);
|
||||
Assert.False(items[0].AlreadyAdded);
|
||||
Assert.Equal("repo-a", items[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCandidates_ExistingWorkingDir_IsMarkedAlreadyAdded()
|
||||
{
|
||||
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
|
||||
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var existing = new HashSet<string>(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
|
||||
|
||||
Assert.Single(items);
|
||||
Assert.True(items[0].AlreadyAdded);
|
||||
Assert.True(items[0].IsChecked); // already-added rows render ticked
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCandidates_SkipsPathsAlreadyShown()
|
||||
{
|
||||
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
|
||||
var current = new HashSet<string>(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase);
|
||||
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
|
||||
|
||||
Assert.Empty(items);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoImportCandidatesTests`
|
||||
Expected: FAIL — `RepoImportModalViewModel` does not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Implement the modal VM**
|
||||
|
||||
Create `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class RepoImportModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ObservableCollection<RepoImportItemViewModel> Repos { get; } = new();
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded);
|
||||
public bool CanCreate => CreateCount > 0;
|
||||
public string CreateButtonText => $"Create {CreateCount} list(s)";
|
||||
|
||||
public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
Repos.Clear();
|
||||
_existingDirs.Clear();
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var lists = new ListRepository(ctx);
|
||||
foreach (var l in await lists.GetAllAsync(ct))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(l.WorkingDir))
|
||||
_existingDirs.Add(l.WorkingDir!);
|
||||
}
|
||||
NotifyCreateState();
|
||||
}
|
||||
|
||||
public void AddFolders(IEnumerable<string> folders)
|
||||
{
|
||||
var current = new HashSet<string>(
|
||||
Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
var found = RepoScanner.Scan(folder);
|
||||
foreach (var item in BuildCandidates(found, current, _existingDirs))
|
||||
{
|
||||
item.PropertyChanged += OnItemChanged;
|
||||
Repos.Add(item);
|
||||
current.Add(item.FullPath);
|
||||
}
|
||||
}
|
||||
NotifyCreateState();
|
||||
}
|
||||
|
||||
public static List<RepoImportItemViewModel> BuildCandidates(
|
||||
IEnumerable<RepoCandidate> found,
|
||||
IReadOnlySet<string> currentPaths,
|
||||
IReadOnlySet<string> existingDirs)
|
||||
{
|
||||
var items = new List<RepoImportItemViewModel>();
|
||||
foreach (var c in found)
|
||||
{
|
||||
if (currentPaths.Contains(c.FullPath)) continue;
|
||||
items.Add(new RepoImportItemViewModel
|
||||
{
|
||||
Name = c.Name,
|
||||
FullPath = c.FullPath,
|
||||
AlreadyAdded = existingDirs.Contains(c.FullPath),
|
||||
IsChecked = true,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private void OnItemChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(RepoImportItemViewModel.IsChecked))
|
||||
NotifyCreateState();
|
||||
}
|
||||
|
||||
private void NotifyCreateState()
|
||||
{
|
||||
OnPropertyChanged(nameof(CreateCount));
|
||||
OnPropertyChanged(nameof(CanCreate));
|
||||
OnPropertyChanged(nameof(CreateButtonText));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
var toCreate = Repos.Where(r => r.IsChecked && !r.AlreadyAdded).ToList();
|
||||
if (toCreate.Count > 0)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var lists = new ListRepository(ctx);
|
||||
foreach (var r in toCreate)
|
||||
{
|
||||
await lists.AddAsync(new ListEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Name = r.Name,
|
||||
WorkingDir = r.FullPath,
|
||||
DefaultCommitType = CommitTypeRegistry.DefaultType,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel() => CloseAction?.Invoke();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoImportCandidatesTests`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs
|
||||
git commit -m "feat(ui): add RepoImportModalViewModel with candidate merge logic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: RepoImportModalView
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml`
|
||||
- Create: `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs`
|
||||
|
||||
Modeled on `AboutModalView.axaml` (header/body/footer) and `ListSettingsModalView.axaml.cs` (folder picker).
|
||||
|
||||
- [ ] **Step 1: Create the view XAML**
|
||||
|
||||
Create `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml`:
|
||||
|
||||
```xml
|
||||
<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.RepoImportModalView"
|
||||
x:DataType="vm:RepoImportModalViewModel"
|
||||
Title="Add repos as lists"
|
||||
Width="560" Height="480"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1">
|
||||
<Grid RowDefinitions="36,Auto,*,52">
|
||||
|
||||
<!-- Header -->
|
||||
<Border Grid.Row="0" Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
|
||||
<TextBlock Text="ADD REPOS AS LISTS" 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 CancelCommand}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Add folder row -->
|
||||
<Border Grid.Row="1" Padding="16,12,16,4">
|
||||
<Button Content="Add folder…" Click="AddFolderClicked" HorizontalAlignment="Left"/>
|
||||
</Border>
|
||||
|
||||
<!-- Repo checklist -->
|
||||
<ScrollViewer Grid.Row="2" Padding="16,4,16,8">
|
||||
<ItemsControl ItemsSource="{Binding Repos}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:RepoImportItemViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,4">
|
||||
<CheckBox Grid.Column="0"
|
||||
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
||||
IsEnabled="{Binding CanToggle}"
|
||||
VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Column="1" Margin="6,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource TextBrush}" FontSize="13"/>
|
||||
<TextBlock Text="{Binding FullPath}" Foreground="{DynamicResource TextFaintBrush}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="(already added)"
|
||||
Foreground="{DynamicResource TextFaintBrush}" FontSize="11"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding AlreadyAdded}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Footer -->
|
||||
<Border Grid.Row="3" 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="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
|
||||
<Button Content="{Binding CreateButtonText}" Command="{Binding CreateCommand}"
|
||||
IsEnabled="{Binding CanCreate}" MinWidth="120" Classes="accent"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the code-behind with folder picker**
|
||||
|
||||
Create `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs`:
|
||||
|
||||
```csharp
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Modals;
|
||||
|
||||
public partial class RepoImportModalView : Window
|
||||
{
|
||||
public RepoImportModalView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
BeginMoveDrag(e);
|
||||
}
|
||||
|
||||
private async void AddFolderClicked(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not RepoImportModalViewModel vm) return;
|
||||
var top = TopLevel.GetTopLevel(this);
|
||||
if (top is null) return;
|
||||
|
||||
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Choose folders containing repos",
|
||||
AllowMultiple = true,
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
|
||||
vm.AddFolders(folders.Select(f => f.Path.LocalPath));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: Build succeeded. (`TitleBar_PointerPressed` is unused for now but kept for parity with other modals; if the build warns as error, leave it — other modals keep the same handler.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs
|
||||
git commit -m "feat(ui): add RepoImportModalView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: DI registration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.App/Program.cs:106` (after `ListSettingsModalViewModel` registration)
|
||||
|
||||
- [ ] **Step 1: Register the modal VM and its factory**
|
||||
|
||||
In `src/ClaudeDo.App/Program.cs`, after the line `sc.AddTransient<ListSettingsModalViewModel>();` add:
|
||||
|
||||
```csharp
|
||||
sc.AddTransient<RepoImportModalViewModel>();
|
||||
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
|
||||
```
|
||||
|
||||
(`RepoImportModalViewModel` is in namespace `ClaudeDo.Ui.ViewModels.Modals`, already imported in `Program.cs` via the existing modal VM usings — verify the using is present; if not, add `using ClaudeDo.Ui.ViewModels.Modals;`.)
|
||||
|
||||
- [ ] **Step 2: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.App/Program.cs
|
||||
git commit -m "chore(di): register RepoImportModalViewModel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Lists island entry point
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs`
|
||||
|
||||
- [ ] **Step 1: Add Func + command to the VM**
|
||||
|
||||
In `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`, next to the existing `ShowListSettingsModal` property (around line 30), add:
|
||||
|
||||
```csharp
|
||||
public Func<RepoImportModalViewModel, System.Threading.Tasks.Task>? ShowRepoImportModal { get; set; }
|
||||
```
|
||||
|
||||
Then add a command (place it near `CreateListAsync`, e.g. after the `OpenWorktreesOverviewAsync` command around line 71):
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task OpenRepoImportAsync()
|
||||
{
|
||||
if (ShowRepoImportModal is null || _services is null) return;
|
||||
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
|
||||
await vm.LoadAsync();
|
||||
await ShowRepoImportModal(vm);
|
||||
await LoadAsync();
|
||||
}
|
||||
```
|
||||
|
||||
(`RepoImportModalViewModel` is in `ClaudeDo.Ui.ViewModels.Modals`, already imported at the top of this file.)
|
||||
|
||||
- [ ] **Step 2: Add the folder button in XAML**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`, replace the existing `+ New list` button block (lines 171-183) with a row that holds both the new-list button and a folder-scan button:
|
||||
|
||||
```xml
|
||||
<!-- New list + import row -->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="new-list-btn"
|
||||
Command="{Binding CreateListCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<PathIcon Data="{StaticResource Icon.Plus}"
|
||||
Width="13" Height="13"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="New list" FontSize="12"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Grid.Column="1" Classes="icon-btn" Margin="6,0,0,0"
|
||||
Command="{Binding OpenRepoImportCommand}"
|
||||
ToolTip.Tip="Add repos as lists">
|
||||
<PathIcon Data="{StaticResource Icon.Folder}"
|
||||
Width="14" Height="14"
|
||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire the Func in the code-behind**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs`, inside the `DataContextChanged` handler (after the `vm.ShowWorktreesOverviewModal = ...` assignment, before the closing brace of the `if` block around line 66), add:
|
||||
|
||||
```csharp
|
||||
vm.ShowRepoImportModal = async modal =>
|
||||
{
|
||||
var window = new RepoImportModalView { DataContext = modal };
|
||||
modal.CloseAction = () => window.Close();
|
||||
var top = TopLevel.GetTopLevel(this) as Window;
|
||||
if (top is null) window.Show();
|
||||
else await window.ShowDialog(top);
|
||||
};
|
||||
```
|
||||
|
||||
(`RepoImportModalView` is in `ClaudeDo.Ui.Views.Modals`, already imported in this file.)
|
||||
|
||||
- [ ] **Step 4: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs
|
||||
git commit -m "feat(ui): add repo import button to Lists island"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Help-menu entry point
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
||||
- Modify: `src/ClaudeDo.App/Program.cs` (pass the Func into the shell VM)
|
||||
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
|
||||
|
||||
- [ ] **Step 1: Add Func, factory field, and command to the shell VM**
|
||||
|
||||
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`:
|
||||
|
||||
(a) Near the `ShowAboutModal` property (line 44), add:
|
||||
|
||||
```csharp
|
||||
public Func<RepoImportModalViewModel, Task>? ShowRepoImportModal { get; set; }
|
||||
```
|
||||
|
||||
(b) Add a backing field for the factory next to `_worktreesOverviewVmFactory` (declared as a private readonly field elsewhere in the class). Add:
|
||||
|
||||
```csharp
|
||||
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
|
||||
```
|
||||
|
||||
(c) Add a parameter to the public constructor (line 162-171) — append after `mergeVmFactory`:
|
||||
|
||||
```csharp
|
||||
Func<MergeModalViewModel> mergeVmFactory,
|
||||
Func<RepoImportModalViewModel> repoImportVmFactory)
|
||||
```
|
||||
|
||||
and in the constructor body assign it (next to `_mergeVmFactory = mergeVmFactory;`):
|
||||
|
||||
```csharp
|
||||
_repoImportVmFactory = repoImportVmFactory;
|
||||
```
|
||||
|
||||
(d) Add the command near `OpenAbout` (line 256):
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async Task OpenRepoImport()
|
||||
{
|
||||
if (ShowRepoImportModal is null || _repoImportVmFactory is null) return;
|
||||
var vm = _repoImportVmFactory();
|
||||
await vm.LoadAsync();
|
||||
await ShowRepoImportModal(vm);
|
||||
if (Lists is not null) await Lists.LoadAsync();
|
||||
}
|
||||
```
|
||||
|
||||
(`RepoImportModalViewModel` is in `ClaudeDo.Ui.ViewModels.Modals`, already imported in this file.)
|
||||
|
||||
- [ ] **Step 2: Pass the Func into the shell VM in DI**
|
||||
|
||||
`IslandsShellViewModel` is registered with `sc.AddSingleton<IslandsShellViewModel>();` (Program.cs:123), which resolves constructor params from the container. Since Task 5 registered `Func<RepoImportModalViewModel>`, no change to the registration call is required — the new constructor parameter resolves automatically. Verify by building in Step 5.
|
||||
|
||||
- [ ] **Step 3: Add the Help-menu item**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, inside the Help `MenuItem` (after the `About…` item at line 74), add:
|
||||
|
||||
```xml
|
||||
<MenuItem Header="Add repos as lists…" Command="{Binding OpenRepoImportCommand}"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire the Func in MainWindow code-behind**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, inside `OnDataContextChanged` (after the `vm.ShowWorktreesOverviewModal = ...` block, before the closing brace of the `if` at line 65), add:
|
||||
|
||||
```csharp
|
||||
vm.ShowRepoImportModal = async (modal) =>
|
||||
{
|
||||
var dlg = new RepoImportModalView { DataContext = modal };
|
||||
modal.CloseAction = () => dlg.Close();
|
||||
await dlg.ShowDialog(this);
|
||||
};
|
||||
```
|
||||
|
||||
(`RepoImportModalView` is in `ClaudeDo.Ui.Views.Modals`, already imported in this file.)
|
||||
|
||||
- [ ] **Step 5: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||
Expected: Build succeeded (this also builds the Ui project).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
|
||||
git commit -m "feat(ui): add 'Add repos as lists' Help-menu entry point"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Manual verification + docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Run the full Ui test suite**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests`
|
||||
Expected: PASS (all tests, including the new `RepoScannerTests` and `RepoImportCandidatesTests`).
|
||||
|
||||
- [ ] **Step 2: Manual smoke test**
|
||||
|
||||
Launch the app (`dotnet run --project src/ClaudeDo.App/ClaudeDo.App.csproj`). Verify:
|
||||
- Lists island shows a folder button next to `+ New list`; clicking it opens the modal.
|
||||
- Help menu shows `Add repos as lists…`; clicking it opens the same modal.
|
||||
- `Add folder…` → pick a parent folder containing git repos → repos appear as ticked rows; non-repo subfolders are absent.
|
||||
- A repo that already has a list appears ticked, disabled, with `(already added)`.
|
||||
- The confirm button reads `Create N list(s)` and is disabled when N is 0.
|
||||
- Confirming creates the lists; they appear in the Lists island immediately after the modal closes.
|
||||
|
||||
Note: if you cannot run the GUI in this environment, state that explicitly rather than claiming the UI works.
|
||||
|
||||
- [ ] **Step 3: Update CLAUDE.md**
|
||||
|
||||
In `src/ClaudeDo.Ui/CLAUDE.md`, under the `## Views` section, add a bullet:
|
||||
|
||||
```markdown
|
||||
- **RepoImportModalView** — bulk-creates lists from git repos discovered under chosen parent folders. Opened via the folder button beside "New list" in the Lists island, or the "Add repos as lists…" Help-menu item. Repos already wired to a list show as disabled/"(already added)".
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/CLAUDE.md
|
||||
git commit -m "docs(ui): document RepoImportModalView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** Entry points (Help menu — Task 7; Lists island button — Task 6); `RepoScanner` non-recursive `.git` dir/file detection (Task 1); `RepoImportModalViewModel` load existing dirs + merge + create (Task 3); already-added disabled rows + `(already added)` label (Tasks 2/3/4); combined multi-folder checklist with path dedupe (Task 3 `AddFolders`); defaults Name/WorkingDir/DefaultCommitType (Task 3 `CreateAsync`); reload Lists island after close (Tasks 6/7); DI registration (Task 5); tests for scanner + merge logic (Tasks 1/3). All spec sections map to a task.
|
||||
- **Type consistency:** `RepoCandidate(Name, FullPath)`, `RepoScanner.Scan`, `RepoImportItemViewModel{Name,FullPath,AlreadyAdded,CanToggle,IsChecked}`, `RepoImportModalViewModel{Repos,CreateCount,CanCreate,CreateButtonText,LoadAsync,AddFolders,BuildCandidates,CreateCommand,CancelCommand,ShowRepoImportModal,CloseAction}` used consistently across tasks.
|
||||
- **YAGNI:** No recursive scan, no inline rename, no per-list model/prompt/agent during import — all explicitly out of scope.
|
||||
655
docs/superpowers/plans/2026-05-29-worker-per-user-autostart.md
Normal file
655
docs/superpowers/plans/2026-05-29-worker-per-user-autostart.md
Normal file
@@ -0,0 +1,655 @@
|
||||
# Worker Per-User Autostart Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the worker's Windows service with a per-user logon Scheduled Task so the worker runs as the logged-in user (Claude auth works), windowless, with file logging and auto-restart.
|
||||
|
||||
**Architecture:** Worker becomes a windowless (`WinExe`) process with Serilog file logging and a single-instance mutex. The installer registers a hidden logon Scheduled Task (via `schtasks /Create /XML`), migrates away the old `ClaudeDoWorker` service, and manages the worker as a process. The app launches/restarts the worker as a process and ensures it's running.
|
||||
|
||||
**Tech Stack:** .NET 8, ASP.NET Core (worker), WPF (installer), Avalonia (app), Serilog, Windows Task Scheduler (`schtasks`), `sc.exe`.
|
||||
|
||||
**Build note:** `.slnx` fails on .NET 8 — always build individual `.csproj` files.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Worker**
|
||||
- Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — WinExe, Serilog packages, drop Hosting.WindowsServices.
|
||||
- Modify `src/ClaudeDo.Worker/Program.cs` — mutex, Serilog, remove `UseWindowsService`.
|
||||
|
||||
**Installer**
|
||||
- Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs` — pure XML builder.
|
||||
- Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` — migrate service + register task.
|
||||
- Rename/rewrite `StopServiceStep.cs` → `StopWorkerStep.cs`, `StartServiceStep.cs` → `StartWorkerStep.cs`.
|
||||
- Delete `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs`.
|
||||
- Modify `Pages/ServicePage/ServicePageViewModel.cs` + `ServicePageView.xaml` — drop account radios.
|
||||
- Modify `Core/InstallContext.cs` — drop `ServiceAccount`.
|
||||
- Modify `Pages/InstallPage/InstallPageViewModel.cs` — pipeline wiring.
|
||||
- Modify `App.xaml.cs` — DI registration.
|
||||
- Modify `Core/UninstallRunner.cs` — task delete + process kill.
|
||||
- Modify `Views/SettingsViewModel.cs` — use renamed steps.
|
||||
|
||||
**App**
|
||||
- Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs` — resolve worker exe path.
|
||||
- Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — process restart + ensure-running.
|
||||
- Modify `src/ClaudeDo.App/Program.cs` — register `WorkerLocator`, pass to shell VM if needed.
|
||||
|
||||
**Tests**
|
||||
- Create `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`.
|
||||
- Create `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Worker → WinExe + Serilog packages
|
||||
|
||||
**Files:** Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
|
||||
- [ ] **Step 1:** In the main `<PropertyGroup>` add `<OutputType>WinExe</OutputType>`. Remove the `Microsoft.Extensions.Hosting.WindowsServices` PackageReference. Add:
|
||||
|
||||
```xml
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds (packages restore).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Worker single-instance mutex + Serilog + drop UseWindowsService
|
||||
|
||||
**Files:** Modify `src/ClaudeDo.Worker/Program.cs`
|
||||
|
||||
- [ ] **Step 1:** At the very top of the file (before `var cfg = WorkerConfig.Load();`), add the single-instance guard:
|
||||
|
||||
```csharp
|
||||
using System.Threading;
|
||||
|
||||
// Single-instance per user session. Multiple launch paths exist (logon task,
|
||||
// app ensure-running, Restart button); a second instance exits cleanly instead
|
||||
// of fighting over the SignalR port.
|
||||
var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew);
|
||||
if (!createdNew)
|
||||
return; // another instance already owns the port; exit 0
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Remove the `builder.Host.UseWindowsService(...)` line (lines ~21-23 incl. the comment).
|
||||
|
||||
- [ ] **Step 3:** After `var builder = WebApplication.CreateBuilder(args);`, add Serilog file logging:
|
||||
|
||||
```csharp
|
||||
using Serilog;
|
||||
|
||||
var logRoot = ClaudeDo.Data.Paths.Expand(cfg.LogRoot);
|
||||
Directory.CreateDirectory(logRoot);
|
||||
builder.Host.UseSerilog((ctx, lc) => lc
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.File(
|
||||
System.IO.Path.Combine(logRoot, "worker-.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 7,
|
||||
shared: true));
|
||||
```
|
||||
|
||||
(If `cfg.LogRoot` is already absolute/expanded, `Paths.Expand` is a safe no-op. Verify `WorkerConfig` exposes `LogRoot`; if the property differs, use the actual name.)
|
||||
|
||||
- [ ] **Step 4:** At the very end of the file, after the run block, add `GC.KeepAlive(mutex);` to ensure the mutex isn't collected.
|
||||
|
||||
- [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds.
|
||||
|
||||
- [ ] **Step 6:** Run worker tests: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — Expected: all pass (set `CLAUDEDO_SKIP_CLI_PREFLIGHT=1` if needed; existing tests already handle this).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Scheduled-task XML builder (pure, TDD)
|
||||
|
||||
**Files:** Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs`, Test `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test:**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Installer.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public class ScheduledTaskXmlTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_EmbedsUserExeAndLogonTrigger()
|
||||
{
|
||||
var xml = ScheduledTaskXml.Build(
|
||||
userId: "MACHINE\\mika",
|
||||
workerExePath: @"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe",
|
||||
restartIntervalMinutes: 1);
|
||||
|
||||
Assert.Contains("<LogonTrigger>", xml);
|
||||
Assert.Contains("<UserId>MACHINE\\mika</UserId>", xml);
|
||||
Assert.Contains("<LogonType>InteractiveToken</LogonType>", xml);
|
||||
Assert.Contains("<Hidden>true</Hidden>", xml);
|
||||
Assert.Contains("<RunLevel>LeastPrivilege</RunLevel>", xml);
|
||||
Assert.Contains(@"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe", xml);
|
||||
Assert.Contains("<Interval>PT1M</Interval>", xml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ClampsRestartIntervalToOneMinuteMinimum()
|
||||
{
|
||||
var xml = ScheduledTaskXml.Build("M\\u", @"C:\w.exe", restartIntervalMinutes: 0);
|
||||
Assert.Contains("<Interval>PT1M</Interval>", xml);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, verify fail:** `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj --filter ScheduledTaskXmlTests` — Expected: FAIL (type missing).
|
||||
|
||||
- [ ] **Step 3: Implement:**
|
||||
|
||||
```csharp
|
||||
using System.Security;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
/// <summary>Builds a Task Scheduler definition XML for the per-user worker autostart.
|
||||
/// Pure function so it can be unit-tested without admin rights.</summary>
|
||||
public static class ScheduledTaskXml
|
||||
{
|
||||
public static string Build(string userId, string workerExePath, int restartIntervalMinutes)
|
||||
{
|
||||
var minutes = restartIntervalMinutes < 1 ? 1 : restartIntervalMinutes;
|
||||
var user = SecurityElement.Escape(userId);
|
||||
var cmd = SecurityElement.Escape(workerExePath);
|
||||
return $"""
|
||||
<?xml version="1.0" encoding="UTF-16"?>
|
||||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<Description>ClaudeDo background worker (per-user).</Description>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<LogonTrigger>
|
||||
<Enabled>true</Enabled>
|
||||
<UserId>{user}</UserId>
|
||||
</LogonTrigger>
|
||||
</Triggers>
|
||||
<Principals>
|
||||
<Principal id="Author">
|
||||
<UserId>{user}</UserId>
|
||||
<LogonType>InteractiveToken</LogonType>
|
||||
<RunLevel>LeastPrivilege</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>true</AllowHardTerminate>
|
||||
<StartWhenAvailable>true</StartWhenAvailable>
|
||||
<Hidden>true</Hidden>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<RestartOnFailure>
|
||||
<Interval>PT{minutes}M</Interval>
|
||||
<Count>3</Count>
|
||||
</RestartOnFailure>
|
||||
</Settings>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
<Command>{cmd}</Command>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>
|
||||
""";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run, verify pass:** same filter — Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: RegisterAutostartStep (migrate service + register task)
|
||||
|
||||
**Files:** Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs`
|
||||
|
||||
- [ ] **Step 1: Implement** (no unit test — shells out to `sc`/`schtasks`; logic kept thin):
|
||||
|
||||
```csharp
|
||||
using System.IO;
|
||||
using System.Security.Principal;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class RegisterAutostartStep : IInstallStep
|
||||
{
|
||||
public const string TaskName = "ClaudeDoWorker";
|
||||
private const string LegacyServiceName = "ClaudeDoWorker";
|
||||
|
||||
public string Name => "Register Autostart";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
|
||||
if (!File.Exists(workerExe))
|
||||
return StepResult.Fail($"Worker executable not found: {workerExe}");
|
||||
|
||||
// 1) Migrate away the legacy Windows service if present.
|
||||
progress.Report("Checking for legacy worker service...");
|
||||
var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
|
||||
if (queryExit == 0)
|
||||
{
|
||||
progress.Report("Removing legacy worker service...");
|
||||
await ProcessRunner.RunAsync("sc.exe", $"stop {LegacyServiceName}", null, progress, ct);
|
||||
await ProcessRunner.RunAsync("sc.exe", $"delete {LegacyServiceName}", null, progress, ct);
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var (q, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
|
||||
if (q != 0) break;
|
||||
await Task.Delay(1000, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Register (or replace) the per-user logon task.
|
||||
var userId = WindowsIdentity.GetCurrent().Name;
|
||||
var minutes = Math.Max(1, ctx.RestartDelayMs / 60000);
|
||||
var xml = ScheduledTaskXml.Build(userId, workerExe, minutes);
|
||||
|
||||
var xmlPath = Path.Combine(Path.GetTempPath(), $"ClaudeDoWorker-{Guid.NewGuid():N}.xml");
|
||||
await File.WriteAllTextAsync(xmlPath, xml, new System.Text.UnicodeEncoding(false, true), ct);
|
||||
try
|
||||
{
|
||||
progress.Report("Registering logon task...");
|
||||
var (exit, output) = await ProcessRunner.RunAsync(
|
||||
"schtasks.exe", $"/Create /TN \"{TaskName}\" /XML \"{xmlPath}\" /F", null, progress, ct);
|
||||
if (exit != 0)
|
||||
return StepResult.Fail($"schtasks /Create failed (exit {exit}): {output}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(xmlPath); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
return StepResult.Ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build:** `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds (after Task 5/6 it compiles fully; if `RestartDelayMs` exists on `InstallContext` already, this compiles now).
|
||||
|
||||
---
|
||||
|
||||
## Task 5: StopWorkerStep + StartWorkerStep (replace service steps)
|
||||
|
||||
**Files:** Create `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`, `src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`. Delete `StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`.
|
||||
|
||||
- [ ] **Step 1: Create `StopWorkerStep.cs`:**
|
||||
|
||||
```csharp
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class StopWorkerStep : IInstallStep
|
||||
{
|
||||
public const string TaskName = "ClaudeDoWorker";
|
||||
public const string ProcessName = "ClaudeDo.Worker";
|
||||
|
||||
public string Name => "Stop Worker";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report("Stopping worker task (if running)...");
|
||||
await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct);
|
||||
|
||||
progress.Report("Stopping worker process (if running)...");
|
||||
var installDir = ctx.InstallDirectory;
|
||||
foreach (var p in Process.GetProcessesByName(ProcessName))
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = p.MainModule?.FileName;
|
||||
if (path is not null && !IsUnder(path, installDir)) continue;
|
||||
p.Kill(entireProcessTree: true);
|
||||
p.WaitForExit(10000);
|
||||
}
|
||||
catch { /* process may have exited or be inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
private static bool IsUnder(string filePath, string dir)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir)) return true; // can't scope — be permissive
|
||||
var full = Path.GetFullPath(filePath);
|
||||
var root = Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return full.StartsWith(root, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `StartWorkerStep.cs`:**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class StartWorkerStep : IInstallStep
|
||||
{
|
||||
public const string TaskName = "ClaudeDoWorker";
|
||||
|
||||
public string Name => "Start Worker";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report("Starting worker...");
|
||||
var (exit, output) = await ProcessRunner.RunAsync("schtasks.exe", $"/Run /TN \"{TaskName}\"", null, progress, ct);
|
||||
if (exit != 0)
|
||||
return StepResult.Fail($"schtasks /Run failed (exit {exit}): {output}");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** Delete `src/ClaudeDo.Installer/Steps/StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`.
|
||||
|
||||
- [ ] **Step 4:** Grep for remaining references: `StopServiceStep`, `StartServiceStep`, `RegisterServiceStep` across `src/` — fix each (Tasks 6-9 cover them).
|
||||
|
||||
---
|
||||
|
||||
## Task 6: InstallContext + ServicePage cleanup
|
||||
|
||||
**Files:** Modify `src/ClaudeDo.Installer/Core/InstallContext.cs`, `Pages/ServicePage/ServicePageViewModel.cs`, `Pages/ServicePage/ServicePageView.xaml`
|
||||
|
||||
- [ ] **Step 1:** In `InstallContext.cs` remove the `ServiceAccount` property (keep `AutoStart`, `RestartDelayMs`, `SignalRPort`, `ClaudeBin`, etc.).
|
||||
|
||||
- [ ] **Step 2:** In `ServicePageViewModel.cs` remove `IsLocalSystem`/`IsCurrentUser` `[ObservableProperty]` fields and the `_context.ServiceAccount = ...` line in `ApplyAsync`. Keep port/claudeBin/autostart/restartDelay.
|
||||
|
||||
- [ ] **Step 3:** In `ServicePageView.xaml` remove the radio buttons / account-selection UI bound to those properties. Leave the rest.
|
||||
|
||||
- [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 7-9.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Pipeline wiring + DI
|
||||
|
||||
**Files:** Modify `Pages/InstallPage/InstallPageViewModel.cs`, `App.xaml.cs`
|
||||
|
||||
- [ ] **Step 1:** In `InstallPageViewModel.LoadAsync`, update the **Update** display steps to:
|
||||
|
||||
```csharp
|
||||
Steps.Add(new StepViewModel("Stop Worker"));
|
||||
Steps.Add(new StepViewModel("Download and Extract"));
|
||||
Steps.Add(new StepViewModel("Register Autostart"));
|
||||
Steps.Add(new StepViewModel("Start Worker"));
|
||||
Steps.Add(new StepViewModel("Write Install Manifest"));
|
||||
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
|
||||
```
|
||||
|
||||
And the **Fresh** display steps to:
|
||||
|
||||
```csharp
|
||||
Steps.Add(new StepViewModel("Download and Extract"));
|
||||
Steps.Add(new StepViewModel("Write Configuration"));
|
||||
Steps.Add(new StepViewModel("Initialize Database"));
|
||||
Steps.Add(new StepViewModel("Register Autostart"));
|
||||
Steps.Add(new StepViewModel("Create Shortcuts"));
|
||||
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
|
||||
Steps.Add(new StepViewModel("Write Install Manifest"));
|
||||
Steps.Add(new StepViewModel("Start Worker"));
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** In `RunInstallAsync`, set the Update execution list to:
|
||||
|
||||
```csharp
|
||||
steps = new IInstallStep[]
|
||||
{
|
||||
_serviceProvider.GetRequiredService<StopWorkerStep>(),
|
||||
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
|
||||
_serviceProvider.GetRequiredService<RegisterAutostartStep>(),
|
||||
_serviceProvider.GetRequiredService<StartWorkerStep>(),
|
||||
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
|
||||
_serviceProvider.GetRequiredService<WriteUninstallRegistryStep>(),
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** In `App.xaml.cs` `BuildServices`, replace the service-step registrations. Fresh-install `IInstallStep` order must be: Download, WriteConfig, InitDatabase, **RegisterAutostart**, CreateShortcuts, WriteUninstallRegistry, WriteInstallManifest, **StartWorker**. Register:
|
||||
|
||||
```csharp
|
||||
sc.AddSingleton<DownloadAndExtractStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
|
||||
sc.AddSingleton<IInstallStep, WriteConfigStep>();
|
||||
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
||||
sc.AddSingleton<RegisterAutostartStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
|
||||
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
||||
sc.AddSingleton<WriteUninstallRegistryStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteUninstallRegistryStep>());
|
||||
sc.AddSingleton<WriteInstallManifestStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
|
||||
sc.AddSingleton<StartWorkerStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartWorkerStep>());
|
||||
|
||||
// Not part of the default fresh IEnumerable<IInstallStep> — pulled individually.
|
||||
sc.AddSingleton<StopWorkerStep>();
|
||||
```
|
||||
|
||||
Remove old `StopServiceStep`/`StartServiceStep`/`RegisterServiceStep` registrations.
|
||||
|
||||
- [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 8-9.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: SettingsViewModel + UninstallRunner
|
||||
|
||||
**Files:** Modify `Views/SettingsViewModel.cs`, `Core/UninstallRunner.cs`
|
||||
|
||||
- [ ] **Step 1:** In `SettingsViewModel.cs`, change ctor params/fields `StopServiceStep`/`StartServiceStep` → `StopWorkerStep`/`StartWorkerStep` (rename type usages only; the Save/Repair logic stays). Update the `Repair` step array to `{ _stopWorker, _downloadStep, _startWorker }`.
|
||||
|
||||
- [ ] **Step 2:** In `UninstallRunner.cs`:
|
||||
- Constructor param `StopServiceStep` → `StopWorkerStep` (field too).
|
||||
- Replace `sc.exe delete ClaudeDoWorker` with task removal + legacy service cleanup:
|
||||
|
||||
```csharp
|
||||
// 3) Unregister autostart task + remove any legacy service.
|
||||
progress.Report("Removing autostart task...");
|
||||
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{StopWorkerStep.TaskName}\" /F", null, progress, ct);
|
||||
await ProcessRunner.RunAsync("sc.exe", "delete ClaudeDoWorker", null, progress, ct); // legacy, best-effort
|
||||
```
|
||||
|
||||
- The existing `_stopService.ExecuteAsync` call becomes `_stopWorker.ExecuteAsync` (kills the worker process before deleting files).
|
||||
|
||||
- [ ] **Step 3:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: **succeeds, 0 errors**.
|
||||
|
||||
- [ ] **Step 4:** Run installer tests: `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj` — Expected: all pass (incl. new `ScheduledTaskXmlTests`).
|
||||
|
||||
---
|
||||
|
||||
## Task 9: App WorkerLocator (TDD)
|
||||
|
||||
**Files:** Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs`, Test `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write failing test:**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Ui.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.Services;
|
||||
|
||||
public class WorkerLocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void FindByWalkingUp_FindsWorkerExeBesideInstallJson()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "claudedo_wl_" + Guid.NewGuid().ToString("N"));
|
||||
var appDir = Path.Combine(root, "app");
|
||||
var workerDir = Path.Combine(root, "worker");
|
||||
Directory.CreateDirectory(appDir);
|
||||
Directory.CreateDirectory(workerDir);
|
||||
File.WriteAllText(Path.Combine(root, "install.json"), "{}");
|
||||
var exe = Path.Combine(workerDir, "ClaudeDo.Worker.exe");
|
||||
File.WriteAllText(exe, "");
|
||||
|
||||
try
|
||||
{
|
||||
var found = new WorkerLocator().FindByWalkingUp(appDir);
|
||||
Assert.Equal(exe, found);
|
||||
}
|
||||
finally { Directory.Delete(root, recursive: true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindByWalkingUp_ReturnsNullWhenNoManifest()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "claudedo_wl_none_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try { Assert.Null(new WorkerLocator().FindByWalkingUp(dir)); }
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, verify fail:** `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter WorkerLocatorTests` — Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement** (mirror `InstallerLocator`):
|
||||
|
||||
```csharp
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed class WorkerLocator
|
||||
{
|
||||
private const string InstallJson = "install.json";
|
||||
private const string WorkerExe = "ClaudeDo.Worker.exe";
|
||||
private const string WorkerSubdir = "worker";
|
||||
|
||||
public string? Find()
|
||||
=> FindByWalkingUp(AppContext.BaseDirectory)
|
||||
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
|
||||
|
||||
public string? FindByWalkingUp(string startDir)
|
||||
{
|
||||
var dir = new DirectoryInfo(startDir);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, InstallJson)))
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, WorkerSubdir, WorkerExe);
|
||||
return File.Exists(candidate) ? candidate : null;
|
||||
}
|
||||
dir = dir.Parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
public string? FindByRegistry()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return null;
|
||||
try
|
||||
{
|
||||
using var key = Microsoft.Win32.Registry.LocalMachine
|
||||
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
|
||||
var location = key?.GetValue("InstallLocation") as string;
|
||||
if (string.IsNullOrEmpty(location)) return null;
|
||||
var candidate = Path.Combine(location, WorkerSubdir, WorkerExe);
|
||||
return File.Exists(candidate) ? candidate : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run, verify pass.**
|
||||
|
||||
---
|
||||
|
||||
## Task 10: App restart-worker + ensure-running
|
||||
|
||||
**Files:** Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, `src/ClaudeDo.App/Program.cs`
|
||||
|
||||
- [ ] **Step 1:** In `App/Program.cs` register the locator: `sc.AddSingleton<WorkerLocator>();` and ensure `IslandsShellViewModel` receives it (constructor injection; the VM is `AddSingleton<IslandsShellViewModel>()` so DI supplies it).
|
||||
|
||||
- [ ] **Step 2:** In `IslandsShellViewModel`, add a `WorkerLocator` constructor dependency and store it. Replace `RestartWorkerService` (the `ServiceController` version) with a process relaunch:
|
||||
|
||||
```csharp
|
||||
private void RestartWorkerService()
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
|
||||
|
||||
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
|
||||
{
|
||||
try { p.Kill(entireProcessTree: true); p.WaitForExit(10000); }
|
||||
catch { /* may have exited */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
|
||||
}
|
||||
```
|
||||
|
||||
Update `RestartWorkerAsync` messages accordingly (drop the "service not installed" `InvalidOperationException` branch wording → generic failure).
|
||||
|
||||
- [ ] **Step 3:** Add ensure-running on startup. After the VM wires up the worker connection, schedule a one-shot check:
|
||||
|
||||
```csharp
|
||||
private bool _ensureRunningAttempted;
|
||||
|
||||
private async Task EnsureWorkerRunningAsync()
|
||||
{
|
||||
if (_ensureRunningAttempted) return;
|
||||
_ensureRunningAttempted = true;
|
||||
await Task.Delay(TimeSpan.FromSeconds(4));
|
||||
if (_worker.IsConnected) return;
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is null) return;
|
||||
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); }
|
||||
catch { /* logon task is the primary mechanism; this is a convenience */ }
|
||||
}
|
||||
```
|
||||
|
||||
Call `_ = EnsureWorkerRunningAsync();` from the VM's existing init path (where the connection is started). Use the actual `WorkerClient` field name and its `IsConnected` member.
|
||||
|
||||
- [ ] **Step 4:** Remove `using System.ServiceProcess;` and the `ServiceController` usage. Remove the `System.ServiceProcess.ServiceProcess` package reference from `ClaudeDo.Ui.csproj` if present and now unused.
|
||||
|
||||
- [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` — Expected: succeeds.
|
||||
|
||||
- [ ] **Step 6:** Run UI tests: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj` — Expected: all pass (incl. `WorkerLocatorTests`). If `IslandsShellViewModel` construction is exercised in a test, supply a `WorkerLocator` instance.
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Full build + test sweep
|
||||
|
||||
- [ ] **Step 1:** Build each project:
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
|
||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
|
||||
```
|
||||
Expected: all succeed, 0 errors.
|
||||
|
||||
- [ ] **Step 2:** Run all test projects:
|
||||
```bash
|
||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
|
||||
dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj
|
||||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj
|
||||
dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj
|
||||
```
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 3:** Grep for leftovers: `ServiceController`, `UseWindowsService`, `RegisterServiceStep`, `StopServiceStep`, `StartServiceStep`, `ServiceAccount` in `src/` — Expected: no matches (except the legacy `sc delete ClaudeDoWorker` migration/cleanup strings).
|
||||
|
||||
---
|
||||
|
||||
## Notes for the implementer
|
||||
- Worker config property for the log directory: confirm the exact name on `WorkerConfig` (spec assumes `LogRoot`). Use the real one.
|
||||
- `ProcessRunner.RunAsync` signature is `(string file, string args, string? workingDir, IProgress<string> progress, CancellationToken ct)` returning `(int ExitCode, string Output)` — match existing call sites.
|
||||
- Keep the legacy `sc delete ClaudeDoWorker` calls (migration + uninstall) so existing service installs are cleaned up.
|
||||
970
docs/superpowers/plans/2026-05-30-external-mcp-ui-parity.md
Normal file
970
docs/superpowers/plans/2026-05-30-external-mcp-ui-parity.md
Normal file
@@ -0,0 +1,970 @@
|
||||
# External MCP — UI Parity (Start & Observe) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add MCP tools so an external Claude session can fully *start* and *observe* ClaudeDo sessions (list/config management, run history, logs, agent listing, reset-failed, app-settings read), reaching UI parity for those concerns.
|
||||
|
||||
**Architecture:** New focused `[McpServerToolType]` classes in `src/ClaudeDo.Worker/External/`, each injecting an existing worker service (no logic duplication). All registered in the *external* `WebApplication` DI container in `Program.cs`. Mutations broadcast the same SignalR events the hub raises, keeping the UI in sync.
|
||||
|
||||
**Tech Stack:** .NET 8, `ModelContextProtocol.Server`, EF Core (SQLite), xUnit integration tests (real SQLite via `DbFixture`).
|
||||
|
||||
> **Build/test note (from project memory):** `dotnet build ClaudeDo.slnx` fails on .NET 8. Build the csproj directly:
|
||||
> `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
> Test: `dotnet test tests/ClaudeDo.Worker.Tests`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create:**
|
||||
- `src/ClaudeDo.Worker/External/ListMcpTools.cs` — list create/update/delete tools
|
||||
- `src/ClaudeDo.Worker/External/ConfigMcpTools.cs` — list-config + task-config tools + DTO
|
||||
- `src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs` — run history + log read tools + DTO
|
||||
- `src/ClaudeDo.Worker/External/AgentMcpTools.cs` — agent listing tool
|
||||
- `src/ClaudeDo.Worker/External/LifecycleMcpTools.cs` — reset-failed-task tool
|
||||
- `src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs` — app-settings read tool
|
||||
- `tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs`
|
||||
- `tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs`
|
||||
- `tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs`
|
||||
- `tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs`
|
||||
|
||||
**Modify:**
|
||||
- `src/ClaudeDo.Worker/Program.cs:188-217` — register new tool classes + services in the external builder
|
||||
- `src/ClaudeDo.Worker/CLAUDE.md:27` — remove stale tag tools, refresh the External MCP tool inventory
|
||||
|
||||
**Reference (existing, do not change):**
|
||||
- `ListRepository` — `AddAsync`, `UpdateAsync`, `DeleteAsync`, `GetByIdAsync`, `GetAllAsync`, `GetConfigAsync`, `SetConfigAsync`, `DeleteConfigAsync`
|
||||
- `TaskRepository.UpdateAgentSettingsAsync(taskId, model?, systemPrompt?, agentPath?)`
|
||||
- `TaskRunRepository` — `GetByTaskIdAsync`, `GetByIdAsync`, `GetLatestByTaskIdAsync`
|
||||
- `TaskResetService.ResetAsync(taskId, ct)` — refuses Running, discards worktree, resets to Idle
|
||||
- `AgentFileService.ScanAsync(ct)` → `List<AgentInfo>`; `AgentInfo(string Name, string Description, string Path)`
|
||||
- `AppSettingsRepository.GetAsync()` → `AppSettingsEntity`
|
||||
- `TaskRunEntity` fields: `Id, TaskId, RunNumber, SessionId, IsRetry, ResultMarkdown, StructuredOutputJson, ErrorMarkdown, ExitCode, TurnCount, TokensIn, TokensOut, LogPath, StartedAt, FinishedAt`
|
||||
- `CommitTypeRegistry.DefaultType`
|
||||
- `HubBroadcaster.ListUpdated(id)`, `.TaskUpdated(id)`
|
||||
|
||||
> **Spec refinement (YAGNI):** the spec listed an agent "refresh" tool. `AgentFileService.ScanAsync` reads disk fresh on every call, so a separate refresh is redundant for an MCP client. We implement `ListAgents` only.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: List management tools (`ListMcpTools`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/External/ListMcpTools.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
internal sealed class ListToolsHubClients : IHubClients
|
||||
{
|
||||
public ListToolsClientProxy Proxy { get; } = new();
|
||||
public IClientProxy All => Proxy;
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> e) => Proxy;
|
||||
public IClientProxy Client(string c) => Proxy;
|
||||
public IClientProxy Clients(IReadOnlyList<string> c) => Proxy;
|
||||
public IClientProxy Group(string g) => Proxy;
|
||||
public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => Proxy;
|
||||
public IClientProxy Groups(IReadOnlyList<string> g) => Proxy;
|
||||
public IClientProxy User(string u) => Proxy;
|
||||
public IClientProxy Users(IReadOnlyList<string> u) => Proxy;
|
||||
}
|
||||
internal sealed class ListToolsClientProxy : IClientProxy
|
||||
{
|
||||
public Task SendCoreAsync(string m, object?[] a, CancellationToken ct = default) => Task.CompletedTask;
|
||||
}
|
||||
internal sealed class ListToolsHubContext : IHubContext<WorkerHub>
|
||||
{
|
||||
public ListToolsHubClients RecordingClients { get; } = new();
|
||||
public IHubClients Clients => RecordingClients;
|
||||
public IGroupManager Groups => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public sealed class ListMcpToolsTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly ListMcpTools _sut;
|
||||
|
||||
public ListMcpToolsTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_lists = new ListRepository(_ctx);
|
||||
_sut = new ListMcpTools(_lists, new HubBroadcaster(new ListToolsHubContext()));
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
[Fact]
|
||||
public async Task CreateList_PersistsWithDefaults()
|
||||
{
|
||||
var dto = await _sut.CreateList("My List", null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("My List", dto.Name);
|
||||
var loaded = await _lists.GetByIdAsync(dto.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("chore", loaded!.DefaultCommitType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateList_PatchesNameWorkingDirAndCommitType()
|
||||
{
|
||||
var created = await _sut.CreateList("orig", null, null, CancellationToken.None);
|
||||
|
||||
var dto = await _sut.UpdateList(created.Id, "renamed", "C:/work", "feat", CancellationToken.None);
|
||||
|
||||
Assert.Equal("renamed", dto.Name);
|
||||
Assert.Equal("C:/work", dto.WorkingDir);
|
||||
var loaded = await _lists.GetByIdAsync(created.Id);
|
||||
Assert.Equal("feat", loaded!.DefaultCommitType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateList_NotFound_Throws()
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.UpdateList("missing", "x", null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteList_RemovesList()
|
||||
{
|
||||
var created = await _sut.CreateList("gone", null, null, CancellationToken.None);
|
||||
|
||||
await _sut.DeleteList(created.Id, CancellationToken.None);
|
||||
|
||||
Assert.Null(await _lists.GetByIdAsync(created.Id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests`
|
||||
Expected: FAIL — `ListMcpTools` does not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record ListSummaryDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class ListMcpTools
|
||||
{
|
||||
private readonly ListRepository _lists;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
|
||||
public ListMcpTools(ListRepository lists, HubBroadcaster broadcaster)
|
||||
{
|
||||
_lists = lists;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")]
|
||||
public async Task<ListSummaryDto> CreateList(
|
||||
string name, string? workingDir, string? commitType, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new InvalidOperationException("name is required.");
|
||||
|
||||
var entity = new ListEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = name,
|
||||
WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir,
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _lists.AddAsync(entity, cancellationToken);
|
||||
await _broadcaster.ListUpdated(entity.Id);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")]
|
||||
public async Task<ListSummaryDto> UpdateList(
|
||||
string listId, string? name, string? workingDir, string? commitType, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
|
||||
if (name is not null) entity.Name = name;
|
||||
if (workingDir is not null)
|
||||
entity.WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir;
|
||||
if (commitType is not null)
|
||||
entity.DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType;
|
||||
|
||||
await _lists.UpdateAsync(entity, cancellationToken);
|
||||
await _broadcaster.ListUpdated(listId);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Delete a list and its tasks. Irreversible.")]
|
||||
public async Task DeleteList(string listId, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
await _lists.DeleteAsync(listId, cancellationToken);
|
||||
await _broadcaster.ListUpdated(listId);
|
||||
}
|
||||
|
||||
private static ListSummaryDto ToDto(ListEntity l) =>
|
||||
new(l.Id, l.Name, l.WorkingDir, l.DefaultCommitType);
|
||||
}
|
||||
```
|
||||
|
||||
> If `CommitTypeRegistry` is not in scope, add `using ClaudeDo.Data;` (verify its namespace with a quick grep before assuming).
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ListMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs
|
||||
git commit -m "feat(worker): add external MCP list-management tools"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: List & task config tools (`ConfigMcpTools`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
public sealed class ConfigMcpToolsTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ConfigMcpTools _sut;
|
||||
|
||||
public ConfigMcpToolsTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_lists = new ListRepository(_ctx);
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_sut = new ConfigMcpTools(_lists, _tasks, new HubBroadcaster(new ListToolsHubContext()));
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private async Task<string> SeedListAsync()
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
return id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAndGetListConfig_RoundTrips()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
|
||||
await _sut.SetListConfig(listId, "sonnet", "be terse", null, CancellationToken.None);
|
||||
var cfg = await _sut.GetListConfig(listId, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(cfg);
|
||||
Assert.Equal("sonnet", cfg!.Model);
|
||||
Assert.Equal("be terse", cfg.SystemPrompt);
|
||||
Assert.Null(cfg.AgentPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetListConfig_AllNull_ClearsConfig()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
await _sut.SetListConfig(listId, "sonnet", null, null, CancellationToken.None);
|
||||
|
||||
await _sut.SetListConfig(listId, null, null, null, CancellationToken.None);
|
||||
|
||||
Assert.Null(await _sut.GetListConfig(listId, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetTaskConfig_PersistsOverrides()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = "t",
|
||||
Status = ClaudeDo.Data.Models.TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
|
||||
await _sut.SetTaskConfig(task.Id, "opus", null, null, CancellationToken.None);
|
||||
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal("opus", loaded!.Model);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests`
|
||||
Expected: FAIL — `ConfigMcpTools` does not exist.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class ConfigMcpTools
|
||||
{
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
|
||||
public ConfigMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster)
|
||||
{
|
||||
_lists = lists;
|
||||
_tasks = tasks;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns null if no config is set.")]
|
||||
public async Task<TaskConfigDto?> GetListConfig(string listId, CancellationToken cancellationToken)
|
||||
{
|
||||
var cfg = await _lists.GetConfigAsync(listId, cancellationToken);
|
||||
return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Set a list's default model/system prompt/agent path. Passing all three as null clears the list config.")]
|
||||
public async Task SetListConfig(
|
||||
string listId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
|
||||
var m = Nullify(model);
|
||||
var sp = Nullify(systemPrompt);
|
||||
var ap = Nullify(agentPath);
|
||||
|
||||
if (m is null && sp is null && ap is null)
|
||||
await _lists.DeleteConfigAsync(listId, cancellationToken);
|
||||
else
|
||||
await _lists.SetConfigAsync(new ListConfigEntity
|
||||
{
|
||||
ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap,
|
||||
}, cancellationToken);
|
||||
|
||||
await _broadcaster.ListUpdated(listId);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path). Pass null to clear a field.")]
|
||||
public async Task SetTaskConfig(
|
||||
string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
await _tasks.UpdateAgentSettingsAsync(taskId, Nullify(model), Nullify(systemPrompt), Nullify(agentPath), cancellationToken);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
|
||||
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
}
|
||||
```
|
||||
|
||||
> Verify `UpdateAgentSettingsAsync` accepts a `CancellationToken` (read `TaskRepository.cs:157`). If it does not, drop the `cancellationToken` argument from that call.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ConfigMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs
|
||||
git commit -m "feat(worker): add external MCP list/task config tools"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Run history & log tools (`RunHistoryMcpTools`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRunRepository _runs;
|
||||
private readonly RunHistoryMcpTools _sut;
|
||||
|
||||
public RunHistoryMcpToolsTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_runs = new TaskRunRepository(_ctx);
|
||||
_sut = new RunHistoryMcpTools(_runs);
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private async Task SeedTaskAsync(string taskId)
|
||||
{
|
||||
var lists = new ListRepository(_ctx);
|
||||
var tasks = new TaskRepository(_ctx);
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
await tasks.AddAsync(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "t",
|
||||
Status = ClaudeDo.Data.Models.TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore",
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListRuns_ReturnsProjectedRuns()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
await _runs.AddAsync(new TaskRunEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
||||
IsRetry = false, Prompt = "p", ResultMarkdown = "done", TokensIn = 10, TokensOut = 20,
|
||||
});
|
||||
|
||||
var list = await _sut.ListRuns(taskId, CancellationToken.None);
|
||||
|
||||
Assert.Single(list);
|
||||
Assert.Equal("done", list[0].ResultMarkdown);
|
||||
Assert.Equal(10, list[0].TokensIn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskLog_NoLog_Throws()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.GetTaskLog(taskId, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskLog_ReadsLatestRunLogFile()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
|
||||
await File.WriteAllTextAsync(logPath, "hello log");
|
||||
await _runs.AddAsync(new TaskRunEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
||||
IsRetry = false, Prompt = "p", LogPath = logPath,
|
||||
});
|
||||
|
||||
var content = await _sut.GetTaskLog(taskId, CancellationToken.None);
|
||||
|
||||
Assert.Equal("hello log", content);
|
||||
File.Delete(logPath);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests`
|
||||
Expected: FAIL — `RunHistoryMcpTools` does not exist.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record RunDto(
|
||||
string Id, int RunNumber, string? SessionId, bool IsRetry,
|
||||
string? ResultMarkdown, string? StructuredOutputJson, string? ErrorMarkdown,
|
||||
int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut,
|
||||
DateTime? StartedAt, DateTime? FinishedAt);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class RunHistoryMcpTools
|
||||
{
|
||||
private readonly TaskRunRepository _runs;
|
||||
|
||||
public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs;
|
||||
|
||||
[McpServerTool, Description("List all execution runs for a task (newest run metadata, tokens, turns, result, error).")]
|
||||
public async Task<IReadOnlyList<RunDto>> ListRuns(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken);
|
||||
return runs.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Get a single execution run by its run id.")]
|
||||
public async Task<RunDto> GetRun(string runId, CancellationToken cancellationToken)
|
||||
{
|
||||
var run = await _runs.GetByIdAsync(runId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Run {runId} not found.");
|
||||
return ToDto(run);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")]
|
||||
public async Task<string> GetTaskLog(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"No runs found for task {taskId}.");
|
||||
if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
|
||||
throw new InvalidOperationException("No log available for the latest run.");
|
||||
return await File.ReadAllTextAsync(run.LogPath, cancellationToken);
|
||||
}
|
||||
|
||||
private static RunDto ToDto(TaskRunEntity r) => new(
|
||||
r.Id, r.RunNumber, r.SessionId, r.IsRetry,
|
||||
r.ResultMarkdown, r.StructuredOutputJson, r.ErrorMarkdown,
|
||||
r.ExitCode, r.TurnCount, r.TokensIn, r.TokensOut,
|
||||
r.StartedAt, r.FinishedAt);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs
|
||||
git commit -m "feat(worker): add external MCP run-history and log tools"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Agent listing tool (`AgentMcpTools`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/External/AgentMcpTools.cs`
|
||||
- Test: none new — covered indirectly; `AgentFileService` already has unit coverage. (This tool is a thin pass-through.)
|
||||
|
||||
- [ ] **Step 1: Write minimal implementation**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.Agents;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class AgentMcpTools
|
||||
{
|
||||
private readonly AgentFileService _agents;
|
||||
|
||||
public AgentMcpTools(AgentFileService agents) => _agents = agents;
|
||||
|
||||
[McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")]
|
||||
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
|
||||
=> await _agents.ScanAsync(cancellationToken);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/AgentMcpTools.cs
|
||||
git commit -m "feat(worker): add external MCP agent-listing tool"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Reset-failed-task tool (`LifecycleMcpTools`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/External/LifecycleMcpTools.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs`
|
||||
|
||||
`TaskResetService.ResetAsync` already refuses Running tasks and discards the worktree. The MCP tool adds a guard that the task must be `Failed` (the only sensible reset target via this surface) and delegates.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using ClaudeDo.Worker.Tests.Services;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
public sealed class LifecycleMcpToolsTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
|
||||
public LifecycleMcpToolsTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private LifecycleMcpTools BuildSut()
|
||||
{
|
||||
var cfg = new WorkerConfig
|
||||
{
|
||||
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
|
||||
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
|
||||
};
|
||||
var dbFactory = _db.CreateFactory();
|
||||
var broadcaster = new HubBroadcaster(new ListToolsHubContext());
|
||||
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||
var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger<TaskResetService>.Instance);
|
||||
return new LifecycleMcpTools(_tasks, reset);
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(TaskStatus status)
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t",
|
||||
Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetFailedTask_OnFailed_ResetsToIdle()
|
||||
{
|
||||
var task = await SeedTaskAsync(TaskStatus.Failed);
|
||||
var sut = BuildSut();
|
||||
|
||||
await sut.ResetFailedTask(task.Id, CancellationToken.None);
|
||||
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.Idle, loaded!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetFailedTask_OnNonFailed_Throws()
|
||||
{
|
||||
var task = await SeedTaskAsync(TaskStatus.Done);
|
||||
var sut = BuildSut();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.ResetFailedTask(task.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetFailedTask_NotFound_Throws()
|
||||
{
|
||||
var sut = BuildSut();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.ResetFailedTask("missing", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests`
|
||||
Expected: FAIL — `LifecycleMcpTools` does not exist.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ModelContextProtocol.Server;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class LifecycleMcpTools
|
||||
{
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly TaskResetService _reset;
|
||||
|
||||
public LifecycleMcpTools(TaskRepository tasks, TaskResetService reset)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_reset = reset;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted.")]
|
||||
public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status != TaskStatus.Failed)
|
||||
throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool.");
|
||||
|
||||
await _reset.ResetAsync(taskId, cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests`
|
||||
Expected: PASS (3 tests). (Git-dependent worktree discard is skipped when no worktree row exists — these tasks have none.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/LifecycleMcpTools.cs tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs
|
||||
git commit -m "feat(worker): add external MCP reset-failed-task tool"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: App-settings read tool (`AppSettingsMcpTools`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs`
|
||||
- Test: none new — thin read-only pass-through over `AppSettingsRepository.GetAsync`.
|
||||
|
||||
This tool is read-only by design (writing app settings is out of scope). It uses the db factory (registered as a singleton in the external builder) to open a context per call, mirroring the hub's pattern.
|
||||
|
||||
- [ ] **Step 1: Write minimal implementation**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record AppSettingsReadDto(
|
||||
string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode,
|
||||
string WorktreeStrategy, string? CentralWorktreeRoot,
|
||||
bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class AppSettingsMcpTools
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
|
||||
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
|
||||
|
||||
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")]
|
||||
public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
|
||||
{
|
||||
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var row = await new AppSettingsRepository(ctx).GetAsync();
|
||||
return new AppSettingsReadDto(
|
||||
row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode,
|
||||
row.WorktreeStrategy, row.CentralWorktreeRoot,
|
||||
row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Verify `AppSettingsRepository.GetAsync` signature (it may take a `CancellationToken`). Adjust the call if so. Confirm `AppSettingsEntity` property names match (`DefaultModel`, `DefaultMaxTurns`, `DefaultPermissionMode`, `WorktreeStrategy`, `CentralWorktreeRoot`, `WorktreeAutoCleanupEnabled`, `WorktreeAutoCleanupDays`) — they are used identically in `WorkerHub.GetAppSettings` (lines 206-219).
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs
|
||||
git commit -m "feat(worker): add external MCP app-settings read tool"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Register new tools in the external MCP app
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Program.cs:188-217`
|
||||
|
||||
The external `WebApplication` has its own DI container. Each new tool class and every service it needs must be registered there, and each class added via `.WithTools<T>()`.
|
||||
|
||||
- [ ] **Step 1: Add service + tool registrations**
|
||||
|
||||
In the `if (cfg.ExternalMcpPort > 0)` block, after the existing
|
||||
`externalBuilder.Services.AddScoped<ExternalMcpService>();` line, add:
|
||||
|
||||
```csharp
|
||||
externalBuilder.Services.AddScoped<TaskRunRepository>();
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
|
||||
externalBuilder.Services.AddScoped<TaskResetService>();
|
||||
externalBuilder.Services.AddScoped<ListMcpTools>();
|
||||
externalBuilder.Services.AddScoped<ConfigMcpTools>();
|
||||
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
|
||||
externalBuilder.Services.AddScoped<AgentMcpTools>();
|
||||
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
|
||||
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
|
||||
```
|
||||
|
||||
And extend the `AddMcpServer()` chain:
|
||||
|
||||
```csharp
|
||||
externalBuilder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithTools<ExternalMcpService>()
|
||||
.WithTools<ListMcpTools>()
|
||||
.WithTools<ConfigMcpTools>()
|
||||
.WithTools<RunHistoryMcpTools>()
|
||||
.WithTools<AgentMcpTools>()
|
||||
.WithTools<LifecycleMcpTools>()
|
||||
.WithTools<AppSettingsMcpTools>();
|
||||
```
|
||||
|
||||
> **Verify before editing:** confirm `WorktreeManager` and `AgentFileService` are registered as singletons in the *main* `app` container (grep `Program.cs` for `WorktreeManager` and `AgentFileService`). If `AgentFileService` is constructed with a directory string rather than DI-resolved, register it in the external builder the same way the main app does (e.g. `new AgentFileService(agentsDir)`), not via `GetRequiredService`. `TaskResetService` depends on `WorktreeManager`, `IDbContextFactory`, `HubBroadcaster`, `ITaskStateService`, `ILogger<TaskResetService>` — all already singletons in the external builder except `WorktreeManager` (added above) and the logger (provided by default logging).
|
||||
|
||||
- [ ] **Step 2: Build the worker**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||
Expected: Build succeeded, no DI-related compile errors.
|
||||
|
||||
- [ ] **Step 3: Run the full worker test suite**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
|
||||
Expected: PASS (all existing + new tests).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Program.cs
|
||||
git commit -m "feat(worker): register new external MCP tool classes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Documentation cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/CLAUDE.md:27`
|
||||
|
||||
- [ ] **Step 1: Replace the stale External MCP inventory line**
|
||||
|
||||
Replace the line beginning `- **External/ExternalMcpService** — always-on MCP tools…` with an accurate inventory that drops the (non-existent) tag tools and lists the new surface:
|
||||
|
||||
```markdown
|
||||
- **External/*** — always-on MCP tools for general Claude sessions, organized by concern:
|
||||
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle`/`Queued`), `RunTaskNow`, `CancelTask`, `DeleteTask`
|
||||
- `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList`
|
||||
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `SetTaskConfig`
|
||||
- `RunHistoryMcpTools` — `ListRuns`, `GetRun`, `GetTaskLog`
|
||||
- `AgentMcpTools` — `ListAgents`
|
||||
- `LifecycleMcpTools` — `ResetFailedTask`
|
||||
- `AppSettingsMcpTools` — `GetAppSettings` (read-only)
|
||||
- Purpose is scoped to *starting* and *observing* sessions — no worktree/merge, multi-turn, planning, or app-settings writes. Auth via optional `X-ClaudeDo-Key` header.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/CLAUDE.md
|
||||
git commit -m "docs(worker): correct external MCP tool inventory, drop removed tags"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- List management → Task 1 ✓
|
||||
- List & task config → Task 2 ✓
|
||||
- Run history & logs → Task 3 ✓
|
||||
- Agents (read-only) → Task 4 ✓
|
||||
- Reset failed task → Task 5 ✓
|
||||
- App settings (read-only) → Task 6 ✓
|
||||
- DI wiring (separate external app) → Task 7 ✓
|
||||
- Tag doc cleanup → Task 8 ✓
|
||||
- Out-of-scope items (multi-turn, worktree ops, planning, app-settings writes, tags, agent create/edit) → not implemented ✓
|
||||
|
||||
**Placeholder scan:** No TBD/TODO. The three "verify before editing" notes point at real signatures the implementer must confirm (cancellation-token overloads, `AgentFileService` construction, registry namespaces) — these are verification steps with concrete fallbacks, not placeholders.
|
||||
|
||||
**Type consistency:** `ListSummaryDto`, `TaskConfigDto`, `RunDto`, `AppSettingsReadDto` defined once and used consistently. `AgentInfo` reused directly (no new DTO). Tool method names match between implementation, tests, and the Task-8 doc inventory (`CreateList`/`UpdateList`/`DeleteList`, `GetListConfig`/`SetListConfig`/`SetTaskConfig`, `ListRuns`/`GetRun`/`GetTaskLog`, `ListAgents`, `ResetFailedTask`, `GetAppSettings`).
|
||||
@@ -0,0 +1,118 @@
|
||||
# Planning: Draft → Planned → Queue gate
|
||||
|
||||
**Date:** 2026-05-29
|
||||
**Status:** Approved (design)
|
||||
|
||||
## Problem
|
||||
|
||||
When a planning parent is finalized, `PlanningChainCoordinator.SetupChainAsync` immediately
|
||||
enqueues the entire child chain (child[0] runs, successors wait blocked on their predecessor).
|
||||
There is no review step: a user cannot hold finalized subtasks in a "ready but not running"
|
||||
state, and the "DRAFT" label in the UI is only a derived side effect
|
||||
(`TaskRowViewModel.IsDraft => IsChild && Status == Idle`) with no gate behind it — a draft
|
||||
child already satisfies `CanSendToQueue` and can be queued directly.
|
||||
|
||||
We want an explicit lifecycle for planning children:
|
||||
|
||||
- **Draft** — child of a plan still being built (parent `PlanningPhase == Active`). Not queueable.
|
||||
- **Planned** — child of a finalized plan (parent `PlanningPhase == Finalized`), still `Idle`. Queueable.
|
||||
|
||||
Finalizing a plan promotes its children Draft → Planned **without** queuing anything. The user
|
||||
then explicitly sends the plan to the queue, which builds the sequential chain (today's behavior,
|
||||
just user-triggered). The gate is enforced in both the UI and the server so no path (UI, MCP,
|
||||
external agents) can queue or run a Draft child.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Q1 — Finalize semantics:** Finalize auto-marks children **Planned** (not Draft); nothing is
|
||||
queued until the user explicitly sends to queue. Draft exists only while the plan is unfinalized.
|
||||
- **Q2 — Queue granularity:** A single **parent-level** "Send plan to queue" action queues all
|
||||
Planned children as a sequential chain (reuses `SetupChainAsync`). No per-child queueing.
|
||||
- **Q3 — Enforcement:** UI **and** server. The gate is a server invariant in `TaskStateService`,
|
||||
so MCP / external agents are bound by it too.
|
||||
- **Data model — Approach 1 (derive, no schema change):** Draft/Planned is a pure function of the
|
||||
parent's `PlanningPhase`. No new column, no migration, no parent/child drift.
|
||||
|
||||
## Core invariant
|
||||
|
||||
No schema change. A child task's stage is derived from its parent's `PlanningPhase`:
|
||||
|
||||
| Parent `PlanningPhase` | Child (`Status = Idle`) | Queueable? |
|
||||
|---|---|---|
|
||||
| `Active` (plan being built) | **DRAFT** | no |
|
||||
| `Finalized` | **PLANNED** | yes |
|
||||
|
||||
**Server invariant:** a child task (`ParentTaskId != null`) may transition `Idle → Queued` or
|
||||
`Idle → Running` **only if** its parent's `PlanningPhase == Finalized`. Standalone (non-child)
|
||||
tasks are unaffected.
|
||||
|
||||
A failed/cancelled child returning to `Idle` while its parent is still `Finalized` is therefore
|
||||
"Planned" again and re-queueable — desired.
|
||||
|
||||
## Components
|
||||
|
||||
### Worker / server
|
||||
|
||||
1. **`TaskStateService` transition guard** — the single enforcement point. When a child task is
|
||||
about to enter `Queued` or `Running`, look up the parent's `PlanningPhase`; if it is not
|
||||
`Finalized`, return a failed `TransitionResult` (no exception — consistent with the existing
|
||||
no-throw transition pattern). This covers:
|
||||
- UI single-task enqueue (`SetTaskStatus → Queued`)
|
||||
- `RunNow` (`StartRunningAsync`, `Idle → Running`)
|
||||
- the queue picker's `Queued → Running` claim (defense in depth; a Draft child can't reach
|
||||
`Queued` in the first place)
|
||||
- MCP `UpdateTaskStatus(Queued)` / `RunTaskNow`
|
||||
|
||||
2. **Finalize stops auto-queuing** — audit every `FinalizeAsync(taskId, queueAgentTasks, ct)`
|
||||
call site and pass `queueAgentTasks: false`. Known callers to update: the UI finalize command
|
||||
and the planning-MCP finalize tool. After this, `FinalizeAsync` only flips the parent to
|
||||
`Finalized` (children become Planned); `SetupChainAsync` is no longer invoked from finalize.
|
||||
|
||||
3. **New queue action** — add `WorkerHub.QueuePlan(parentTaskId)` →
|
||||
`PlanningChainCoordinator.SetupChainAsync(parentTaskId)`. Guarded so it only runs when the
|
||||
parent is `Finalized`; otherwise returns a failure the UI surfaces. This is the user-triggered
|
||||
replacement for the auto-chain.
|
||||
|
||||
### UI
|
||||
|
||||
4. **`TaskRowViewModel`**
|
||||
- Add `ParentFinalized` (`bool`), set by `TasksIslandViewModel`.
|
||||
- `IsDraft => IsChild && Status == Idle && !ParentFinalized`
|
||||
- `IsPlanned => IsChild && Status == Idle && ParentFinalized`
|
||||
- `CanSendToQueue` gains `&& (!IsChild || ParentFinalized)`
|
||||
- Child badge renders `DRAFT` / `PLANNED` (drive off `IsDraft` / `IsPlanned`).
|
||||
- Raise `PropertyChanged` for the new derived members from the relevant `On*Changed` hooks
|
||||
(`OnStatusChanged`, `OnParentTaskIdChanged`, and a new `OnParentFinalizedChanged`).
|
||||
|
||||
5. **`TasksIslandViewModel`** — when building/refreshing rows, resolve each child's parent
|
||||
`PlanningPhase` from the loaded task set and set `ParentFinalized`. If the parent is not in the
|
||||
loaded set, default to `false` (Draft — the safe, non-queueable default).
|
||||
|
||||
6. **`DetailsIslandViewModel`**
|
||||
- `CanEnqueue` for a selected child additionally requires the parent to be `Finalized`.
|
||||
- Add a parent-level **"Send plan to queue"** command, enabled when the selected task is a
|
||||
`Finalized` planning parent with at least one Planned (`Idle`) child and nothing already
|
||||
queued/running; calls `QueuePlanAsync(parentId)`.
|
||||
|
||||
7. **`IWorkerClient` / `WorkerClient`** — add `QueuePlanAsync(string parentId)`. Update the test
|
||||
fakes (UI + Worker test projects) to implement the new member.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Worker (`TaskStateService`):** child enqueue/run rejected when parent `Active`; allowed when
|
||||
parent `Finalized`. Standalone task enqueue still allowed. Picker skips/ignores draft children.
|
||||
- **Worker (finalize):** `FinalizeAsync(..., queueAgentTasks: false)` flips parent to `Finalized`
|
||||
and queues nothing; children remain `Idle`.
|
||||
- **Worker (`QueuePlan`):** on a `Finalized` parent, builds the sequential chain (child[0]
|
||||
unblocked + queued, successors blocked on predecessor); on a non-`Finalized` parent, fails.
|
||||
- **UI VM (`TaskRowViewModel`):** Draft vs Planned derivation and `CanSendToQueue` gating across
|
||||
parent phases; badge text.
|
||||
- **UI VM (`DetailsIslandViewModel`):** `CanEnqueue` gating for children; "Send plan to queue"
|
||||
enablement.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-child manual promotion while a plan is still being built (Draft → Planned without
|
||||
finalizing). Promotion happens only via finalize.
|
||||
- Per-child independent queueing (Q2 = parent-level chain only).
|
||||
- Any database schema / migration change.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Repo Import List Helper — Design
|
||||
|
||||
**Date:** 2026-05-29
|
||||
**Status:** Approved (pending spec review)
|
||||
|
||||
## Problem
|
||||
|
||||
Creating lists is one-at-a-time: click `+ New list`, then open List Settings to set the
|
||||
working directory. Users with many repos under a few parent folders want to wire them all up
|
||||
in one pass.
|
||||
|
||||
## Goal
|
||||
|
||||
A "list helper" that scans one or more parent folders for git repos, presents them as a
|
||||
checklist, and bulk-creates a list (with `WorkingDir` pre-filled) for each ticked repo.
|
||||
|
||||
## Entry Points
|
||||
|
||||
1. **Help menu** — the title-bar dropdown in `MainWindow.axaml` that contains `About…`,
|
||||
`Worktrees…`, etc. Add a new `MenuItem` `Add repos as lists…` wired to a command on
|
||||
`MainWindowViewModel`.
|
||||
2. **Lists island** — a small folder icon button beside the existing `+ New list` button in
|
||||
`ListsIslandView.axaml`, wired to a command on `ListsIslandViewModel`.
|
||||
|
||||
Both open the same modal.
|
||||
|
||||
## Components
|
||||
|
||||
### `RepoScanner` (new, `ClaudeDo.Ui/Services` or `ClaudeDo.Data`)
|
||||
|
||||
Pure filesystem helper, no git library. Given a parent folder path, enumerates immediate
|
||||
subdirectories and returns those that contain a `.git` entry (directory or file). Kept
|
||||
separate from the VM so it is unit-testable.
|
||||
|
||||
```
|
||||
IReadOnlyList<RepoCandidate> Scan(string parentFolder)
|
||||
record RepoCandidate(string Name, string FullPath)
|
||||
```
|
||||
|
||||
- Skips the parent itself; only immediate children are considered (non-recursive).
|
||||
- `.git` may be a directory (normal repo) or a file (worktree/submodule) — both count.
|
||||
- Returns empty on missing/unreadable folder rather than throwing.
|
||||
|
||||
### `RepoImportModalViewModel` (new, `ClaudeDo.Ui/ViewModels/Modals`)
|
||||
|
||||
Follows the existing modal-VM pattern (`CloseAction`, resolved from DI).
|
||||
|
||||
Dependencies:
|
||||
- `IDbContextFactory<ClaudeDoDbContext>` — load existing lists' `WorkingDir` values (for the
|
||||
"already added" check) and create new `ListEntity` rows. Same dependency
|
||||
`ListsIslandViewModel` already uses.
|
||||
|
||||
State:
|
||||
- `ObservableCollection<RepoImportItemViewModel> Repos` — the combined checklist.
|
||||
- A set of parent folder paths already scanned (to de-dupe re-adds).
|
||||
- `CreateCount` — computed count of ticked-and-new rows (drives the confirm button label).
|
||||
|
||||
Commands:
|
||||
- `AddFolderAsync` — invokes the folder picker (via view code-behind callback, see below),
|
||||
scans each chosen folder with `RepoScanner`, appends new candidates. De-dupes by full path
|
||||
(case-insensitive) against rows already present.
|
||||
- `CreateAsync` — for each ticked, non-existing row, create a `ListEntity` via
|
||||
`ListRepository.AddAsync` (Name = folder name, WorkingDir = full path,
|
||||
DefaultCommitType = `CommitTypeRegistry.DefaultType`, fresh `Guid` id, `CreatedAt` = now).
|
||||
Then `CloseAction()`.
|
||||
- `Cancel` — `CloseAction()`.
|
||||
|
||||
On load, fetch all existing lists once and capture their `WorkingDir`s into a case-insensitive
|
||||
set; each appended candidate whose path is in that set is marked `AlreadyAdded`.
|
||||
|
||||
### `RepoImportItemViewModel` (new)
|
||||
|
||||
- `Name`, `FullPath` (display).
|
||||
- `AlreadyAdded` (bool) — true if a list already points at this path.
|
||||
- `IsChecked` ([ObservableProperty]) — defaults `true` for new repos. For already-added rows it
|
||||
is forced `true` and the checkbox is disabled.
|
||||
- `CanToggle` => `!AlreadyAdded` (binds to checkbox `IsEnabled`).
|
||||
|
||||
### `RepoImportModalView` (new, `ClaudeDo.Ui/Views/Modals`)
|
||||
|
||||
A `Window` styled like the other modals (header bar, body, footer), shown via
|
||||
`ShowDialog(owner)`.
|
||||
|
||||
- **Header:** title `ADD REPOS AS LISTS` + close button.
|
||||
- **Top of body:** `Add folder…` button.
|
||||
- **Body:** scrollable `ItemsControl` over `Repos`. Each row = `CheckBox` (IsChecked two-way,
|
||||
IsEnabled = `CanToggle`) + repo name + dim full path + `(already added)` label when
|
||||
`AlreadyAdded`.
|
||||
- **Footer:** `Create {CreateCount} lists` button (disabled when `CreateCount == 0`) + `Cancel`.
|
||||
- Folder picker lives in the code-behind (mirrors `ListSettingsModalView.BrowseClicked`):
|
||||
`OpenFolderPickerAsync` with `AllowMultiple = true`, results handed to the VM's
|
||||
`AddFolderAsync`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User opens the modal from either entry point → modal loads existing lists' `WorkingDir`s.
|
||||
2. User clicks `Add folder…` → picks one or more parent folders → `RepoScanner` finds repos →
|
||||
rows appended (de-duped), already-added rows shown ticked+disabled.
|
||||
3. User adjusts ticks → clicks `Create N lists`.
|
||||
4. VM creates one `ListEntity` per ticked-new row via `ListRepository`.
|
||||
5. Modal closes → the **caller reloads the Lists island** so new lists appear:
|
||||
- Lists-island entry point: `ListsIslandViewModel.LoadAsync()`.
|
||||
- Help-menu entry point: `MainWindowViewModel` reloads its `Lists` (the
|
||||
`ListsIslandViewModel` instance) after the modal closes.
|
||||
|
||||
## DI / Wiring
|
||||
|
||||
- Register `RepoImportModalViewModel` (transient) alongside other modal VMs.
|
||||
- Register `RepoScanner` if implemented as an injected service; a static helper needs no
|
||||
registration.
|
||||
- `ListsIslandViewModel` gains `Func<RepoImportModalViewModel, Task>? ShowRepoImportModal` and
|
||||
an `OpenRepoImportCommand`, wired in `ListsIslandView.axaml.cs` (mirrors
|
||||
`ShowListSettingsModal`).
|
||||
- `MainWindowViewModel` gains the same `Func` + an `OpenRepoImportCommand`, wired in
|
||||
`MainWindow.axaml.cs`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Unreadable / missing folders: `RepoScanner` returns empty, no crash.
|
||||
- Re-adding a folder already scanned: de-duped by path, no duplicate rows.
|
||||
- Two ticked repos sharing a folder name: both created (list names are not unique) — acceptable.
|
||||
- List creation failure (rare): best-effort per the existing pattern; do not block remaining
|
||||
creations.
|
||||
|
||||
## Testing
|
||||
|
||||
- `RepoScanner` unit tests (the testable seam): a temp directory tree with a mix of git repos
|
||||
(`.git` dir), a `.git`-file repo, plain folders, and an empty/missing parent. Assert only the
|
||||
repo subfolders are returned and missing folders yield empty.
|
||||
- VM-level "already added" logic and `CreateCount` can be exercised if a test seam is convenient,
|
||||
but the filesystem scanner is the primary unit under test. UI wiring verified manually.
|
||||
|
||||
## Out of Scope (YAGNI)
|
||||
|
||||
- Recursive / deep scanning.
|
||||
- Inline editing of the list name before creation.
|
||||
- Setting model / system prompt / agent during import (tuned later per-list in List Settings).
|
||||
- Picking repo folders directly (only parent-folder scan, per decision).
|
||||
@@ -0,0 +1,165 @@
|
||||
# Worker per-user autostart (drop Windows service)
|
||||
|
||||
Status: approved 2026-05-29
|
||||
Author: brainstorm session (mika kuns + Claude)
|
||||
|
||||
## Problem
|
||||
|
||||
The worker runs as a Windows **service** registered under `LocalSystem`. The worker
|
||||
shells out to the `claude` CLI, whose authentication is stored per-user
|
||||
(`%USERPROFILE%\.claude`). Under `LocalSystem` the worker uses the system profile and
|
||||
cannot see the user's Claude login, so task execution fails. The installer even exposes a
|
||||
"Current User" service-account radio that the backend rejects (`RegisterServiceStep`
|
||||
fails the install). Net effect: the only installable configuration cannot authenticate
|
||||
Claude.
|
||||
|
||||
## Goal
|
||||
|
||||
Run the worker as the logged-in **user** so it inherits the user's Claude auth, starting
|
||||
automatically at logon and staying alive in the background (independent of the desktop
|
||||
app, so Prime/scheduled tasks fire when the UI is closed).
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
1. **Lifetime:** background from logon, always — independent of the UI.
|
||||
2. **Mechanism:** per-user **logon Scheduled Task** (`schtasks`), run only when the user is
|
||||
logged on (no stored password), hidden, with restart-on-failure.
|
||||
3. **No console window:** worker becomes `WinExe`; add a **Serilog rolling file sink** so
|
||||
worker diagnostics aren't lost.
|
||||
4. **App ensures running:** "Restart Worker" becomes process-based; on app startup, if
|
||||
SignalR doesn't connect within a few seconds, the app launches the worker.
|
||||
5. **Auto-migrate:** the installer detects and removes the old `ClaudeDoWorker` service,
|
||||
then registers the task. Uninstall removes the task + kills the worker process.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Cross-account elevation (admin elevates as a *different* account than the interactive
|
||||
user). Single-user / user-is-admin is assumed; the task targets the interactive user.
|
||||
- Running the worker when no user is logged on (that's the whole point — it must be a user
|
||||
session for Claude auth).
|
||||
|
||||
---
|
||||
|
||||
## Component changes
|
||||
|
||||
### ClaudeDo.Worker
|
||||
|
||||
- **`ClaudeDo.Worker.csproj`**: `<OutputType>WinExe</OutputType>`. Add packages
|
||||
`Serilog.AspNetCore` and `Serilog.Sinks.File`.
|
||||
- **`Program.cs`**:
|
||||
- Remove `builder.Host.UseWindowsService(...)`.
|
||||
- Configure Serilog file sink: path `<LogRoot>/worker-.log`, `rollingInterval: Day`,
|
||||
`retainedFileCountLimit: 7`, shared write. `LogRoot` comes from `WorkerConfig`
|
||||
(expand `~`). Wire via `builder.Host.UseSerilog(...)`.
|
||||
- **Single-instance guard:** at startup create `new Mutex(true, @"Local\ClaudeDoWorker",
|
||||
out var createdNew)`. If `!createdNew`, log "another worker instance is already
|
||||
running" and exit 0. Hold the mutex for process lifetime. `Local\` namespace = per
|
||||
user session, which is what we want.
|
||||
- CLI preflight (`ClaudeCliPreflight`) behavior unchanged.
|
||||
|
||||
### ClaudeDo.Installer
|
||||
|
||||
- **New `Steps/RegisterAutostartStep.cs`** (`IInstallStep`, "Register Autostart"):
|
||||
- Build a Task Scheduler **definition XML** (UTF-16) and register via
|
||||
`schtasks /Create /TN "ClaudeDoWorker" /XML "<tmpfile>" /F`.
|
||||
- XML shape:
|
||||
- `Principals/Principal`: `UserId` = current interactive user
|
||||
(`WindowsIdentity.GetCurrent().Name`), `LogonType=InteractiveToken`,
|
||||
`RunLevel=LeastPrivilege`.
|
||||
- `Triggers/LogonTrigger` with the same `UserId`.
|
||||
- `Settings`: `Hidden=true`, `MultipleInstancesPolicy=IgnoreNew`,
|
||||
`StartWhenAvailable=true`, `ExecutionTimeLimit=PT0S`,
|
||||
`DisallowStartIfOnBatteries=false`, `StopIfGoingOnBatteries=false`,
|
||||
`RestartOnFailure` with `Interval` (>= `PT1M`; Task Scheduler's minimum granularity
|
||||
is one minute) and `Count=3`.
|
||||
- `Actions/Exec/Command`: quoted path to `<installDir>/worker/ClaudeDo.Worker.exe`.
|
||||
- The XML builder is a **pure function** (string in → XML string out) so it is unit
|
||||
testable without admin.
|
||||
- **`MigrateServiceStep`** (or folded into `RegisterAutostartStep` as a first phase):
|
||||
detect the old service via `sc query ClaudeDoWorker`; if present, `sc stop` then
|
||||
`sc delete` (poll for clearance like the old `RegisterServiceStep` did). No-op when the
|
||||
service doesn't exist (fresh installs).
|
||||
- **Rename `StopServiceStep` → `StopWorkerStep`, `StartServiceStep` → `StartWorkerStep`**,
|
||||
reworked to be process/task based:
|
||||
- Stop: `schtasks /End /TN ClaudeDoWorker` (ignore errors) + kill any
|
||||
`ClaudeDo.Worker` process whose `MainModule.FileName` is under the install dir;
|
||||
wait for exit. This unlocks `worker/` binaries before extract.
|
||||
- Start: `schtasks /Run /TN ClaudeDoWorker` (preferred — launches as the task principal).
|
||||
Used by fresh install (so the worker runs immediately rather than waiting for next
|
||||
logon) and by Settings "restart".
|
||||
- **`Pages/ServicePage/ServicePageViewModel.cs`**: remove `IsLocalSystem`/`IsCurrentUser`
|
||||
radios and `ServiceAccount` usage. Keep SignalR port, Claude CLI path, "Start at logon"
|
||||
toggle (`AutoStart`), restart delay (maps to task `RestartOnFailure/Interval`, clamped
|
||||
to >= 1 min). Update `ServicePageView.xaml` accordingly. Remove `ServiceAccount` from
|
||||
`InstallContext`.
|
||||
- **`RegisterServiceStep.cs`**: deleted (replaced by `RegisterAutostartStep`).
|
||||
- **Pipelines (`InstallPageViewModel`)**:
|
||||
- Fresh: DownloadAndExtract → WriteConfig → InitDatabase → **RegisterAutostart** (incl.
|
||||
migration no-op) → CreateShortcuts → WriteUninstallRegistry → WriteInstallManifest →
|
||||
**StartWorker**.
|
||||
- Update: **StopWorker** → DownloadAndExtract → **RegisterAutostart** (migrates old
|
||||
service) → **StartWorker** → WriteInstallManifest → WriteUninstallRegistry.
|
||||
- **DI (`App.xaml.cs`)**: register the renamed/new steps (concrete + `IInstallStep` where
|
||||
needed, following the existing double-registration pattern).
|
||||
- **`Core/UninstallRunner.cs`**: replace `sc delete ClaudeDoWorker` with
|
||||
`schtasks /Delete /TN ClaudeDoWorker /F` and kill the worker process; also `sc delete`
|
||||
the legacy service best-effort (in case an old service still lingers).
|
||||
|
||||
### ClaudeDo.Ui / ClaudeDo.App
|
||||
|
||||
- **New `Services/WorkerLocator.cs`**: resolve `<installDir>/worker/ClaudeDo.Worker.exe`
|
||||
by walking up for `install.json` then registry `InstallLocation` (mirrors
|
||||
`InstallerLocator`).
|
||||
- **`ViewModels/IslandsShellViewModel.cs`**:
|
||||
- `RestartWorkerService`: drop `System.ServiceProcess.ServiceController`. Kill worker
|
||||
process(es) under the install dir, then `Process.Start(workerExe)`.
|
||||
- **Ensure-running:** on startup, if the `WorkerClient` connection isn't established
|
||||
within ~4s, launch the worker via `WorkerLocator` + `Process.Start`. Guarded so it
|
||||
runs at most once per app session.
|
||||
- Remove the `System.ServiceProcess` package reference / usings if no longer used.
|
||||
|
||||
---
|
||||
|
||||
## Data flow
|
||||
|
||||
- **Logon:** Task Scheduler starts `ClaudeDo.Worker.exe` in the user session → mutex
|
||||
acquired → Serilog file logging → SignalR hub on `127.0.0.1:47821` → app connects.
|
||||
- **App start with worker down:** app waits ~4s for SignalR; if absent, `Process.Start`
|
||||
worker → mutex acquired → hub up → app connects.
|
||||
- **Duplicate launch (task + app race):** second instance fails the mutex → logs → exits 0.
|
||||
- **Restart Worker button:** kill worker proc → relaunch → mutex reacquired.
|
||||
|
||||
## Error handling
|
||||
|
||||
- `schtasks`/`sc` calls go through the existing `ProcessRunner`; non-zero exits surface as
|
||||
`StepResult.Fail` with the captured output (except best-effort cleanup which is ignored).
|
||||
- Worker single-instance: losing the mutex is a normal, non-error exit (code 0).
|
||||
- App ensure-running: `Process.Start` failures are swallowed (the logon task is the primary
|
||||
mechanism; the app launch is a convenience).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit (no admin required):**
|
||||
- Task-definition XML builder: asserts UserId, LogonType, Hidden, RestartOnFailure
|
||||
interval clamping, quoted command path.
|
||||
- `WorkerLocator`: path resolution via temp `install.json`.
|
||||
- Migration decision: given `sc query` output (exists / not-found), decide stop+delete vs
|
||||
no-op — keep the decision pure, mock `ProcessRunner` output.
|
||||
- Restart-delay → task interval clamping (`< 1 min` → `PT1M`).
|
||||
- **Manual verification (post-build, on this machine):**
|
||||
1. Update from installed `1.0.2-alpha`: old service is removed (`sc query ClaudeDoWorker`
|
||||
→ not found), task exists (`schtasks /Query /TN ClaudeDoWorker`), worker process runs
|
||||
as the user, app connects, no console window.
|
||||
2. Worker log file appears at `~/.todo-app/logs/worker-<date>.log`.
|
||||
3. Kill worker → click Restart Worker in app → reconnects.
|
||||
4. Close app, confirm worker still running (Prime/queue alive); reopen app → connects.
|
||||
5. Log off / log on → worker autostarts.
|
||||
6. Uninstall → task gone, worker process gone, (data kept unless opted out).
|
||||
|
||||
## Risks
|
||||
|
||||
- **Task restart granularity is minutes**, not the old seconds-level service restart. The
|
||||
worker's own long-running resilience + the app ensure-running cover short gaps; acceptable.
|
||||
- **Elevated installer must target the interactive user.** Using
|
||||
`WindowsIdentity.GetCurrent().Name` is correct when the user elevates themselves (the
|
||||
assumed single-user case). Documented non-goal otherwise.
|
||||
@@ -0,0 +1,125 @@
|
||||
# External MCP — UI Parity for Start & Observe
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Status:** Approved (design)
|
||||
|
||||
## Goal
|
||||
|
||||
Expand the always-on **External MCP server** (`ExternalMcpService`, exposed on
|
||||
`cfg.ExternalMcpPort` under `/mcp`) so an external Claude session can **start and
|
||||
observe** ClaudeDo work sessions end-to-end, reaching parity with the desktop UI
|
||||
for those two concerns.
|
||||
|
||||
The server's purpose is deliberately scoped: **help the user start sessions and
|
||||
observe them.** It is *not* a git/worktree console — branch merging, worktree
|
||||
resets, and multi-turn continuation are things Claude does *inside* a task, so
|
||||
they stay out of the tool surface.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
**START — set up and launch a session**
|
||||
- *(existing)* `AddTask`, `UpdateTask`, `UpdateTaskStatus` (Idle/Queued), `RunTaskNow`, `CancelTask`, `DeleteTask`
|
||||
- **List management** — create / rename / delete lists; set working dir + default commit type
|
||||
- **List & task config** — per-list defaults and per-task overrides for `model`, `system_prompt`, `agent_path`
|
||||
- **Agents (read-only)** — list agent files and refresh, so Claude can choose a valid `agent_path`
|
||||
- **Reset failed task** — discard the failed worktree and reset the row to Idle (the retry path)
|
||||
|
||||
**OBSERVE**
|
||||
- *(existing)* `ListTaskLists`, `ListTasks`, `GetTask`
|
||||
- **Run history** — read `task_runs` for a task (session id, tokens, turns, result, structured output, error)
|
||||
- **Logs** — fetch a task's (or run's) log output
|
||||
- **App settings (read-only)** — read worker app settings
|
||||
|
||||
### Out of scope (explicitly excluded)
|
||||
- **Tags** — already removed from the system (migration `20260519044715_RemoveTags`); only the stale doc reference in `src/ClaudeDo.Worker/CLAUDE.md` needs deleting.
|
||||
- **Multi-turn continue** (`--resume`) — Claude's own concern inside a task.
|
||||
- **Worktree ops** — merge, merge targets, cleanup-finished, reset-all, force-remove, set-state.
|
||||
- **Start planning session** — not needed via MCP.
|
||||
- **App settings writes** — risky (e.g. flips permission mode); read-only only.
|
||||
- **Agent file create/edit/delete** — not part of "starting a session".
|
||||
|
||||
## Approach (chosen: A)
|
||||
|
||||
**Reuse existing worker services; split the growing tool surface into focused
|
||||
`[McpServerToolType]` classes.** No business logic is duplicated — each new tool
|
||||
injects the same service the SignalR hub already uses, so MCP behavior stays
|
||||
identical to the UI.
|
||||
|
||||
Adding ~12 tools to the single `ExternalMcpService` would push it past 600 lines
|
||||
across eight unrelated jobs. Instead, organize tools by category, mirroring the
|
||||
existing `External/` + `Planning/` layout:
|
||||
|
||||
| Class (new, in `External/`) | Tools | Backing service |
|
||||
|---|---|---|
|
||||
| `ExternalMcpService` *(existing, unchanged scope)* | task CRUD + run/cancel/status | `TaskRepository`, `QueueService`, `ITaskStateService` |
|
||||
| `ListMcpTools` | `CreateList`, `RenameList`, `DeleteList`, `SetListWorkingDir` (name/dir/commitType) | `ListRepository` |
|
||||
| `ConfigMcpTools` | `GetListConfig`, `SetListConfig`, `SetTaskConfig` (model/system_prompt/agent_path) | `ListRepository`, `TaskRepository.UpdateAgentSettingsAsync` |
|
||||
| `RunHistoryMcpTools` | `ListRuns`, `GetRun`, `GetTaskLog` | `TaskRunRepository`, log file read |
|
||||
| `AgentMcpTools` | `ListAgents`, `RefreshAgents` | `AgentFileService.ScanAsync` |
|
||||
| `LifecycleMcpTools` | `ResetFailedTask` | `TaskResetService.ResetAsync` |
|
||||
| `AppSettingsMcpTools` | `GetAppSettings` | `AppSettingsRepository.GetAsync` |
|
||||
|
||||
(Exact class grouping may be tuned during planning, but each class stays small
|
||||
and single-purpose.)
|
||||
|
||||
## Architecture & wiring
|
||||
|
||||
The external MCP server is a **separate `WebApplication`** built in
|
||||
`Program.cs` (≈ lines 188–217) with its own DI container, distinct from the main
|
||||
SignalR app. Shared singletons (`HubBroadcaster`, `QueueService`,
|
||||
`ITaskStateService`, db factory, `WorkerConfig`) are injected by instance so both
|
||||
apps act on the same runtime state.
|
||||
|
||||
Each new tool class must be:
|
||||
1. Registered in the **external** builder (`externalBuilder.Services.AddScoped<…>()`),
|
||||
alongside any newly required services (`TaskRunRepository`, `AgentFileService`,
|
||||
`TaskResetService` + their dependencies).
|
||||
2. Registered as tools via additional `.WithTools<T>()` calls on the external
|
||||
`AddMcpServer()` chain.
|
||||
|
||||
No change to auth: the existing `ExternalMcpAuthMiddleware` (optional
|
||||
`X-ClaudeDo-Key`, loopback-only otherwise) covers all tools uniformly. No
|
||||
per-tool gating — the surface is read/observe + start, with the one borderline
|
||||
write (`ResetFailedTask`) being a normal retry affordance.
|
||||
|
||||
## Data flow
|
||||
|
||||
- **Start:** Claude calls e.g. `CreateList` → `SetListConfig` → `AddTask(queueImmediately: true)`. Writes go through `ListRepository` / `TaskStateService`, which wake the queue and broadcast `ListUpdated` / `TaskUpdated` so the UI reflects changes live.
|
||||
- **Observe:** Claude calls `ListTasks` / `GetTask` → `ListRuns` / `GetRun` → `GetTaskLog`. Pure reads from `TaskRepository` / `TaskRunRepository` and the log file at `TaskRunEntity.LogPath`.
|
||||
- **Mutations broadcast** the same SignalR events the hub raises, keeping the desktop UI in sync.
|
||||
|
||||
## DTOs
|
||||
|
||||
- `RunDto` — projection of `TaskRunEntity`: `Id`, `RunNumber`, `SessionId`, `IsRetry`, `ResultMarkdown`, `StructuredOutputJson`, `ErrorMarkdown`, `ExitCode`, `TurnCount`, `TokensIn`, `TokensOut`, `StartedAt`, `FinishedAt`.
|
||||
- `AgentDto` — from `AgentInfo` (`Name`, `Description`, `Path`).
|
||||
- `ListConfigDto` — `Model`, `SystemPrompt`, `AgentPath` (reuse the shape already used by the hub).
|
||||
- App-settings read reuses the existing `AppSettingsDto` shape (read-only subset is fine).
|
||||
- Log fetch returns the file contents as a string (with a size cap / tail option decided in planning).
|
||||
|
||||
## Error handling
|
||||
|
||||
Follow the existing `ExternalMcpService` convention: throw
|
||||
`InvalidOperationException` with a clear message for not-found / invalid-input /
|
||||
illegal-state (e.g. "List {id} not found", "Cannot reset a non-failed task").
|
||||
Reuse the guard patterns already present (required-field checks, status checks).
|
||||
`ResetFailedTask` must refuse non-`Failed` tasks.
|
||||
|
||||
## Testing
|
||||
|
||||
Extend `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` (and add
|
||||
sibling test files per new tool class) using the existing real-SQLite + real-git
|
||||
integration pattern:
|
||||
- List CRUD round-trips; rename/delete propagate; delete blocked/handled sensibly.
|
||||
- List + task config set/get round-trips; clearing all three fields removes list config (matches hub behavior).
|
||||
- Run history reads return correct projections; `GetTaskLog` returns file contents and errors cleanly when no log exists.
|
||||
- `ResetFailedTask` succeeds on a Failed task and refuses other statuses.
|
||||
- Agent listing reflects files on disk after refresh.
|
||||
- App-settings read returns current values.
|
||||
|
||||
## Doc cleanup (part of this work)
|
||||
|
||||
- `src/ClaudeDo.Worker/CLAUDE.md` — remove the stale `SetTaskTags` / `ListTags` /
|
||||
"AddTask (with tags)" claim; replace the External MCP tool inventory with the
|
||||
new surface.
|
||||
@@ -19,8 +19,8 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
|
||||
|
||||
## DI Registration Pattern
|
||||
|
||||
- **Singletons**: SqliteConnectionFactory, all Repositories, WorkerClient, MainWindowViewModel, TaskListViewModel, TaskDetailViewModel, StatusBarViewModel
|
||||
- **Transients**: TaskEditorViewModel, ListEditorViewModel (created per dialog)
|
||||
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
|
||||
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ sealed class Program
|
||||
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
|
||||
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
|
||||
sc.AddSingleton<InstallerLocator>();
|
||||
sc.AddSingleton<WorkerLocator>();
|
||||
sc.AddSingleton(sp =>
|
||||
{
|
||||
var releases = sp.GetRequiredService<IReleaseClient>();
|
||||
@@ -102,7 +103,10 @@ sealed class Program
|
||||
sc.AddTransient<PrimeClaudeTabViewModel>();
|
||||
sc.AddTransient<SettingsModalViewModel>();
|
||||
sc.AddTransient<MergeModalViewModel>();
|
||||
sc.AddTransient<Func<MergeModalViewModel>>(sp => () => sp.GetRequiredService<MergeModalViewModel>());
|
||||
sc.AddTransient<ListSettingsModalViewModel>();
|
||||
sc.AddTransient<RepoImportModalViewModel>();
|
||||
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
|
||||
|
||||
// Islands shell VMs
|
||||
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||
|
||||
@@ -7,18 +7,16 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
- **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)
|
||||
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
|
||||
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path)
|
||||
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
|
||||
|
||||
## Repositories
|
||||
|
||||
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim.
|
||||
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here.
|
||||
|
||||
- **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)
|
||||
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `TryCompleteParentAsync`, `UpdateChildAsync`), `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, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
|
||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
||||
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||
|
||||
@@ -35,7 +33,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". The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`).
|
||||
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`).
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
||||
builder.Property(s => s.WorktreeAutoCleanupDays)
|
||||
.HasColumnName("worktree_auto_cleanup_days").IsRequired().HasDefaultValue(7);
|
||||
|
||||
builder.Property(s => s.RepoImportFolders)
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering.Filters;
|
||||
|
||||
public sealed class ImportantFilter : ITaskListFilter
|
||||
{
|
||||
public string Id => "smart:important";
|
||||
public bool Matches(TaskEntity t) => t.IsStarred;
|
||||
public bool ShouldCount(TaskEntity t) => t.IsStarred && t.Status != TaskStatus.Done;
|
||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering.Filters;
|
||||
|
||||
public sealed class MyDayFilter : ITaskListFilter
|
||||
{
|
||||
public string Id => "smart:my-day";
|
||||
public bool Matches(TaskEntity t) => t.IsMyDay;
|
||||
public bool ShouldCount(TaskEntity t) => t.IsMyDay && t.Status != TaskStatus.Done;
|
||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering.Filters;
|
||||
|
||||
public sealed class PlannedFilter : ITaskListFilter
|
||||
{
|
||||
public string Id => "smart:planned";
|
||||
public bool Matches(TaskEntity t) => t.ScheduledFor != null;
|
||||
public bool ShouldCount(TaskEntity t) => t.ScheduledFor != null && t.Status != TaskStatus.Done;
|
||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering.Filters;
|
||||
|
||||
public sealed class QueuedFilter : ITaskListFilter
|
||||
{
|
||||
public string Id => "virtual:queued";
|
||||
public bool Matches(TaskEntity t) => t.Status == TaskStatus.Queued;
|
||||
public bool ShouldCount(TaskEntity t) => t.Status == TaskStatus.Queued;
|
||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
|
||||
PlanningRules.IsPlanningParent(t) &&
|
||||
PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Queued);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering.Filters;
|
||||
|
||||
public sealed class RunningFilter : ITaskListFilter
|
||||
{
|
||||
public string Id => "virtual:running";
|
||||
public bool Matches(TaskEntity t) => t.Status == TaskStatus.Running;
|
||||
public bool ShouldCount(TaskEntity t) => t.Status == TaskStatus.Running;
|
||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
|
||||
PlanningRules.IsPlanningParent(t) &&
|
||||
PlanningRules.HasMatchingChild(t, all, c => c.Status == TaskStatus.Running);
|
||||
}
|
||||
16
src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs
Normal file
16
src/ClaudeDo.Data/Filtering/Filters/SmartFlagFilter.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Filter for a smart list keyed off a boolean/nullable task flag
|
||||
/// (My Day, Important, Planned). Counts only non-done matches.
|
||||
/// </summary>
|
||||
public sealed class SmartFlagFilter(string id, Func<TaskEntity, bool> flag) : ITaskListFilter
|
||||
{
|
||||
public string Id => id;
|
||||
public bool Matches(TaskEntity t) => flag(t);
|
||||
public bool ShouldCount(TaskEntity t) => flag(t) && t.Status != TaskStatus.Done;
|
||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||
}
|
||||
18
src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs
Normal file
18
src/ClaudeDo.Data/Filtering/Filters/StatusFilter.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Virtual list filter matching tasks by a single status (Queued, Running).
|
||||
/// Planning parents appear contextually when they host a matching child.
|
||||
/// </summary>
|
||||
public sealed class StatusFilter(string id, TaskStatus status) : ITaskListFilter
|
||||
{
|
||||
public string Id => id;
|
||||
public bool Matches(TaskEntity t) => t.Status == status;
|
||||
public bool ShouldCount(TaskEntity t) => t.Status == status;
|
||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
|
||||
PlanningRules.IsPlanningParent(t) &&
|
||||
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ClaudeDo.Data.Filtering.Filters;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Data.Filtering;
|
||||
|
||||
@@ -14,11 +15,11 @@ public sealed class TaskListFilterRegistry
|
||||
private static readonly IReadOnlyDictionary<string, ITaskListFilter> BuiltIn =
|
||||
new Dictionary<string, ITaskListFilter>(StringComparer.Ordinal)
|
||||
{
|
||||
["smart:my-day"] = new MyDayFilter(),
|
||||
["smart:important"] = new ImportantFilter(),
|
||||
["smart:planned"] = new PlannedFilter(),
|
||||
["virtual:queued"] = new QueuedFilter(),
|
||||
["virtual:running"] = new RunningFilter(),
|
||||
["smart:my-day"] = new SmartFlagFilter("smart:my-day", t => t.IsMyDay),
|
||||
["smart:important"] = new SmartFlagFilter("smart:important", t => t.IsStarred),
|
||||
["smart:planned"] = new SmartFlagFilter("smart:planned", t => t.ScheduledFor != null),
|
||||
["virtual:queued"] = new StatusFilter("virtual:queued", TaskStatus.Queued),
|
||||
["virtual:running"] = new StatusFilter("virtual:running", TaskStatus.Running),
|
||||
["virtual:review"] = new ReviewFilter(),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRepoImportFolders : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "repo_import_folders",
|
||||
table: "app_settings",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
column: "repo_import_folders",
|
||||
value: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "repo_import_folders",
|
||||
table: "app_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
|
||||
@@ -15,4 +15,7 @@ public sealed class AppSettingsEntity
|
||||
public string? CentralWorktreeRoot { get; set; }
|
||||
public bool WorktreeAutoCleanupEnabled { get; set; }
|
||||
public int WorktreeAutoCleanupDays { get; set; } = 7;
|
||||
|
||||
// JSON array of parent folders remembered by the repo-import modal.
|
||||
public string? RepoImportFolders { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -22,7 +23,7 @@ public sealed class AppSettingsRepository
|
||||
return row;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default)
|
||||
private async Task<AppSettingsEntity> GetOrCreateTrackedRowAsync(CancellationToken ct)
|
||||
{
|
||||
var row = await _context.AppSettings
|
||||
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
|
||||
@@ -31,6 +32,12 @@ public sealed class AppSettingsRepository
|
||||
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
||||
_context.AppSettings.Add(row);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default)
|
||||
{
|
||||
var row = await GetOrCreateTrackedRowAsync(ct);
|
||||
|
||||
row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty;
|
||||
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
|
||||
@@ -45,4 +52,25 @@ public sealed class AppSettingsRepository
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetRepoImportFoldersAsync(CancellationToken ct = default)
|
||||
{
|
||||
var json = await _context.AppSettings.AsNoTracking()
|
||||
.Where(s => s.Id == AppSettingsEntity.SingletonId)
|
||||
.Select(s => s.RepoImportFolders)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json)) return new List<string>();
|
||||
try { return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>(); }
|
||||
catch (JsonException) { return new List<string>(); }
|
||||
}
|
||||
|
||||
public async Task SetRepoImportFoldersAsync(IEnumerable<string> folders, CancellationToken ct = default)
|
||||
{
|
||||
var list = folders.ToList();
|
||||
var row = await GetOrCreateTrackedRowAsync(ct);
|
||||
|
||||
row.RepoImportFolders = list.Count == 0 ? null : JsonSerializer.Serialize(list);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,24 +203,26 @@ public partial class App : Application
|
||||
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
|
||||
|
||||
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
|
||||
// Double-registered as both IInstallStep and concrete type so Task 15's Update pipeline
|
||||
// Double-registered as both IInstallStep and concrete type so the Update pipeline
|
||||
// can pull them out individually via GetRequiredService<T>().
|
||||
sc.AddSingleton<DownloadAndExtractStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
|
||||
sc.AddSingleton<IInstallStep, WriteConfigStep>();
|
||||
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
||||
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartServiceStep>());
|
||||
sc.AddSingleton<RegisterAutostartStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
|
||||
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
||||
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
|
||||
sc.AddSingleton<WriteUninstallRegistryStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteUninstallRegistryStep>());
|
||||
sc.AddSingleton<WriteInstallManifestStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
|
||||
// Start the worker last in the fresh pipeline (binaries + task must exist first).
|
||||
sc.AddSingleton<StartWorkerStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartWorkerStep>());
|
||||
|
||||
// Stop — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
|
||||
// Pulled by Update flow + Repair/Uninstall.
|
||||
sc.AddSingleton<StopServiceStep>();
|
||||
// StartServiceStep is also registered as IInstallStep above (fresh-install pipeline).
|
||||
sc.AddSingleton<StartServiceStep>();
|
||||
sc.AddSingleton<StopWorkerStep>();
|
||||
|
||||
// Runners
|
||||
sc.AddSingleton<UninstallRunner>();
|
||||
|
||||
@@ -5,6 +5,23 @@ using ClaudeDo.Data;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
internal static class JsonConfigFile
|
||||
{
|
||||
public static T LoadOrDefault<T>(string fileName, JsonSerializerOptions readOpts) where T : new()
|
||||
{
|
||||
var path = Path.Combine(Paths.AppDataRoot(), fileName);
|
||||
if (!File.Exists(path)) return new();
|
||||
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), readOpts) ?? new();
|
||||
}
|
||||
|
||||
public static void Save<T>(string fileName, T value, JsonSerializerOptions writeOpts)
|
||||
{
|
||||
var dir = Paths.AppDataRoot();
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(Path.Combine(dir, fileName), JsonSerializer.Serialize(value, writeOpts));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape.
|
||||
/// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs.
|
||||
@@ -47,21 +64,9 @@ public sealed class InstallerWorkerConfig
|
||||
};
|
||||
|
||||
public static InstallerWorkerConfig Load()
|
||||
{
|
||||
var path = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||
if (!File.Exists(path)) return new();
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<InstallerWorkerConfig>(json, ReadOpts) ?? new();
|
||||
}
|
||||
=> JsonConfigFile.LoadOrDefault<InstallerWorkerConfig>("worker.config.json", ReadOpts);
|
||||
|
||||
public void Save()
|
||||
{
|
||||
var dir = Paths.AppDataRoot();
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "worker.config.json");
|
||||
var json = JsonSerializer.Serialize(this, WriteOpts);
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
public void Save() => JsonConfigFile.Save("worker.config.json", this, WriteOpts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -85,25 +90,9 @@ public sealed class InstallerAppSettings
|
||||
|
||||
public static InstallerAppSettings Load()
|
||||
{
|
||||
var path = Path.Combine(Paths.AppDataRoot(), "ui.config.json");
|
||||
if (!File.Exists(path)) return new();
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<InstallerAppSettings>(json, ReadOpts) ?? new();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new();
|
||||
}
|
||||
try { return JsonConfigFile.LoadOrDefault<InstallerAppSettings>("ui.config.json", ReadOpts); }
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
var dir = Paths.AppDataRoot();
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "ui.config.json");
|
||||
var json = JsonSerializer.Serialize(this, WriteOpts);
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
public void Save() => JsonConfigFile.Save("ui.config.json", this, WriteOpts);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ public sealed class InstallContext
|
||||
public int SignalRPort { get; set; } = 47_821;
|
||||
public int QueueBackstopIntervalMs { get; set; } = 30_000;
|
||||
public string ClaudeBin { get; set; } = "claude";
|
||||
public string ServiceAccount { get; set; } = "CurrentUser";
|
||||
public bool AutoStart { get; set; } = true;
|
||||
public int RestartDelayMs { get; set; } = 5000;
|
||||
|
||||
|
||||
52
src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs
Normal file
52
src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Security;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public static class ScheduledTaskXml
|
||||
{
|
||||
public static string Build(string userId, string workerExePath, int restartIntervalMinutes)
|
||||
{
|
||||
var minutes = restartIntervalMinutes < 1 ? 1 : restartIntervalMinutes;
|
||||
var user = SecurityElement.Escape(userId);
|
||||
var cmd = SecurityElement.Escape(workerExePath);
|
||||
return $"""
|
||||
<?xml version="1.0" encoding="UTF-16"?>
|
||||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<Description>ClaudeDo background worker (per-user).</Description>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<LogonTrigger>
|
||||
<Enabled>true</Enabled>
|
||||
<UserId>{user}</UserId>
|
||||
</LogonTrigger>
|
||||
</Triggers>
|
||||
<Principals>
|
||||
<Principal id="Author">
|
||||
<UserId>{user}</UserId>
|
||||
<LogonType>InteractiveToken</LogonType>
|
||||
<RunLevel>LeastPrivilege</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>true</AllowHardTerminate>
|
||||
<StartWhenAvailable>true</StartWhenAvailable>
|
||||
<Hidden>true</Hidden>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<RestartOnFailure>
|
||||
<Interval>PT{minutes}M</Interval>
|
||||
<Count>3</Count>
|
||||
</RestartOnFailure>
|
||||
</Settings>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
<Command>{cmd}</Command>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ namespace ClaudeDo.Installer.Core;
|
||||
public sealed class UninstallRunner
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
private readonly StopServiceStep _stopService;
|
||||
private readonly StopWorkerStep _stopService;
|
||||
|
||||
public UninstallRunner(InstallContext context, StopServiceStep stopService)
|
||||
public UninstallRunner(InstallContext context, StopWorkerStep stopService)
|
||||
{
|
||||
_context = context;
|
||||
_stopService = stopService;
|
||||
@@ -27,16 +27,17 @@ public sealed class UninstallRunner
|
||||
// 2) Stop service. If stop fails we MUST abort — deleting a service whose
|
||||
// process is still running leaves orphan locked binaries under the install dir
|
||||
// which Directory.Delete will silently skip.
|
||||
progress.Report("Stopping worker service...");
|
||||
progress.Report("Stopping worker...");
|
||||
var stopResult = await _stopService.ExecuteAsync(_context, progress, ct);
|
||||
if (!stopResult.Success)
|
||||
return StepResult.Fail(
|
||||
$"Cannot uninstall: worker service did not stop cleanly. {stopResult.ErrorMessage} " +
|
||||
$"Cannot uninstall: worker did not stop cleanly. {stopResult.ErrorMessage} " +
|
||||
"Kill the worker manually and re-run uninstall.");
|
||||
|
||||
// 3) Unregister service.
|
||||
progress.Report("Unregistering service...");
|
||||
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
|
||||
// 3) Unregister the autostart task, and best-effort remove any legacy service.
|
||||
progress.Report("Removing autostart task...");
|
||||
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{StopWorkerStep.TaskName}\" /F", null, progress, ct);
|
||||
await ProcessRunner.RunAsync("sc.exe", "delete ClaudeDoWorker", null, progress, ct);
|
||||
|
||||
// 3b) Remove Apps & Features registry entry (best-effort).
|
||||
progress.Report("Removing Add/Remove Programs entry...");
|
||||
|
||||
@@ -10,6 +10,18 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.InstallPage;
|
||||
|
||||
public partial class StepViewModel : ObservableObject
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
[ObservableProperty] private StepStatus _status = StepStatus.Pending;
|
||||
[ObservableProperty] private bool _isExpanded;
|
||||
|
||||
public ObservableCollection<string> Messages { get; } = [];
|
||||
|
||||
public StepViewModel(string name) => Name = name;
|
||||
}
|
||||
|
||||
public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
@@ -42,20 +54,23 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||
Steps.Clear();
|
||||
if (_context.Mode == InstallerMode.Update)
|
||||
{
|
||||
Steps.Add(new StepViewModel("Stop Worker Service"));
|
||||
Steps.Add(new StepViewModel("Stop Worker"));
|
||||
Steps.Add(new StepViewModel("Download and Extract"));
|
||||
Steps.Add(new StepViewModel("Start Worker Service"));
|
||||
Steps.Add(new StepViewModel("Register Autostart"));
|
||||
Steps.Add(new StepViewModel("Start Worker"));
|
||||
Steps.Add(new StepViewModel("Write Install Manifest"));
|
||||
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Steps.Add(new StepViewModel("Download and Extract"));
|
||||
Steps.Add(new StepViewModel("Write Configuration"));
|
||||
Steps.Add(new StepViewModel("Initialize Database"));
|
||||
Steps.Add(new StepViewModel("Register Windows Service"));
|
||||
Steps.Add(new StepViewModel("Register Autostart"));
|
||||
Steps.Add(new StepViewModel("Create Shortcuts"));
|
||||
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
|
||||
Steps.Add(new StepViewModel("Write Install Manifest"));
|
||||
Steps.Add(new StepViewModel("Start Worker"));
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -116,10 +131,15 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||
{
|
||||
steps = new IInstallStep[]
|
||||
{
|
||||
_serviceProvider.GetRequiredService<StopServiceStep>(),
|
||||
_serviceProvider.GetRequiredService<StopWorkerStep>(),
|
||||
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
|
||||
_serviceProvider.GetRequiredService<StartServiceStep>(),
|
||||
// Migrates the legacy service away and (re)registers the logon task.
|
||||
_serviceProvider.GetRequiredService<RegisterAutostartStep>(),
|
||||
_serviceProvider.GetRequiredService<StartWorkerStep>(),
|
||||
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
|
||||
// Refresh the bundled uninstaller exe + Add/Remove-Programs version so a
|
||||
// manual update also renews the installer that bootstraps future updates.
|
||||
_serviceProvider.GetRequiredService<WriteUninstallRegistryStep>(),
|
||||
};
|
||||
}
|
||||
else
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Installer.Pages.InstallPage;
|
||||
|
||||
public partial class StepViewModel : ObservableObject
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
[ObservableProperty] private StepStatus _status = StepStatus.Pending;
|
||||
[ObservableProperty] private bool _isExpanded;
|
||||
|
||||
public ObservableCollection<string> Messages { get; } = [];
|
||||
|
||||
public StepViewModel(string name) => Name = name;
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel MaxWidth="520">
|
||||
<TextBlock Text="Worker Service" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="Configure the ClaudeDo Worker background service."
|
||||
<TextBlock Text="Worker" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="Configure the ClaudeDo background worker."
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
@@ -33,18 +33,11 @@
|
||||
|
||||
<Separator Margin="0,4,0,12"/>
|
||||
|
||||
<Label Content="Service Account"/>
|
||||
<StackPanel Margin="0,0,0,12">
|
||||
<RadioButton Content="Local System (recommended)"
|
||||
IsChecked="{Binding IsLocalSystem}" Margin="0,0,0,4"/>
|
||||
<RadioButton Content="Current User"
|
||||
IsChecked="{Binding IsCurrentUser}"/>
|
||||
<TextBlock Text="Running as current user requires 'Log on as a service' privilege."
|
||||
Foreground="{StaticResource TextDimBrush}" FontSize="11" Margin="20,2,0,0"
|
||||
TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="The worker runs as you (the logged-in user) via a per-user logon task, so it can use your Claude CLI authentication."
|
||||
Foreground="{StaticResource TextDimBrush}" FontSize="11" Margin="0,0,0,12"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<CheckBox Content="Start service automatically" IsChecked="{Binding AutoStart}" Margin="0,0,0,12"/>
|
||||
<CheckBox Content="Start worker automatically at logon" IsChecked="{Binding AutoStart}" Margin="0,0,0,12"/>
|
||||
|
||||
<Label Content="Restart Delay (ms)"/>
|
||||
<TextBox Text="{Binding RestartDelayMs, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
|
||||
|
||||
@@ -21,8 +21,6 @@ public partial class ServicePageViewModel : ObservableObject, IInstallerPage
|
||||
[ObservableProperty] private int _signalRPort = 47_821;
|
||||
[ObservableProperty] private int _queueBackstopIntervalMs = 30_000;
|
||||
[ObservableProperty] private string _claudeBin = "claude";
|
||||
[ObservableProperty] private bool _isLocalSystem = true;
|
||||
[ObservableProperty] private bool _isCurrentUser;
|
||||
[ObservableProperty] private bool _autoStart = true;
|
||||
[ObservableProperty] private int _restartDelayMs = 5000;
|
||||
[ObservableProperty] private string? _validationError;
|
||||
@@ -43,7 +41,6 @@ public partial class ServicePageViewModel : ObservableObject, IInstallerPage
|
||||
_context.SignalRPort = SignalRPort;
|
||||
_context.QueueBackstopIntervalMs = QueueBackstopIntervalMs;
|
||||
_context.ClaudeBin = ClaudeBin;
|
||||
_context.ServiceAccount = IsCurrentUser ? "CurrentUser" : "LocalSystem";
|
||||
_context.AutoStart = AutoStart;
|
||||
_context.RestartDelayMs = RestartDelayMs;
|
||||
_context.SignalRUrl = $"http://127.0.0.1:{SignalRPort}/hub";
|
||||
|
||||
59
src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs
Normal file
59
src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.IO;
|
||||
using System.Security.Principal;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class RegisterAutostartStep : IInstallStep
|
||||
{
|
||||
public const string TaskName = "ClaudeDoWorker";
|
||||
private const string LegacyServiceName = "ClaudeDoWorker";
|
||||
|
||||
public string Name => "Register Autostart";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
|
||||
if (!File.Exists(workerExe))
|
||||
return StepResult.Fail($"Worker executable not found: {workerExe}");
|
||||
|
||||
// 1) Migrate away the legacy Windows service if present.
|
||||
progress.Report("Checking for legacy worker service...");
|
||||
var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
|
||||
if (queryExit == 0)
|
||||
{
|
||||
progress.Report("Removing legacy worker service...");
|
||||
await ProcessRunner.RunAsync("sc.exe", $"stop {LegacyServiceName}", null, progress, ct);
|
||||
await ProcessRunner.RunAsync("sc.exe", $"delete {LegacyServiceName}", null, progress, ct);
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var (q, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
|
||||
if (q != 0) break;
|
||||
await Task.Delay(1000, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Register (or replace) the per-user logon task.
|
||||
var userId = WindowsIdentity.GetCurrent().Name;
|
||||
var minutes = Math.Max(1, ctx.RestartDelayMs / 60000);
|
||||
var xml = ScheduledTaskXml.Build(userId, workerExe, minutes);
|
||||
|
||||
var xmlPath = Path.Combine(Path.GetTempPath(), $"ClaudeDoWorker-{Guid.NewGuid():N}.xml");
|
||||
await File.WriteAllTextAsync(xmlPath, xml, new System.Text.UnicodeEncoding(false, true), ct);
|
||||
try
|
||||
{
|
||||
progress.Report("Registering logon task...");
|
||||
var (exit, output) = await ProcessRunner.RunAsync(
|
||||
"schtasks.exe", $"/Create /TN \"{TaskName}\" /XML \"{xmlPath}\" /F", null, progress, ct);
|
||||
if (exit != 0)
|
||||
return StepResult.Fail($"schtasks /Create failed (exit {exit}): {output}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(xmlPath); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
return StepResult.Ok();
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class RegisterServiceStep : IInstallStep
|
||||
{
|
||||
private const string ServiceName = "ClaudeDoWorker";
|
||||
|
||||
public string Name => "Register Windows Service";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
|
||||
if (!File.Exists(workerExe))
|
||||
return StepResult.Fail($"Worker executable not found: {workerExe}");
|
||||
|
||||
// Stop existing service (ignore errors — may not exist)
|
||||
progress.Report("Stopping existing service (if any)...");
|
||||
await RunSc($"stop {ServiceName}", ctx, progress, ct, ignoreErrors: true);
|
||||
|
||||
// Delete existing service (ignore errors)
|
||||
progress.Report("Removing existing service registration (if any)...");
|
||||
await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
|
||||
|
||||
// Wait for the service to actually disappear from SCM. `sc delete` returns
|
||||
// immediately but the service stays "marked for deletion" until every open
|
||||
// handle (services.msc, Task Manager, a prior sc query process) is closed.
|
||||
// Poll up to 30s — then fail with actionable guidance if it's still there.
|
||||
progress.Report("Waiting for prior service registration to clear...");
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var (queryExit, _) = await RunSc($"query {ServiceName}", ctx, progress, ct, ignoreErrors: true);
|
||||
if (queryExit != 0) break; // service no longer registered — good
|
||||
if (i == 29)
|
||||
return StepResult.Fail(
|
||||
$"Service '{ServiceName}' is marked for deletion but hasn't cleared after 30s. " +
|
||||
"Close any open Services console (services.msc), Task Manager Services tab, or " +
|
||||
"Event Viewer showing the service, then retry. A reboot will also clear it.");
|
||||
await Task.Delay(1000, ct);
|
||||
}
|
||||
|
||||
// Create service
|
||||
var startType = ctx.AutoStart ? "auto" : "demand";
|
||||
|
||||
if (ctx.ServiceAccount == "CurrentUser")
|
||||
return StepResult.Fail(
|
||||
"Service cannot run as Current User without a password. " +
|
||||
"Select 'Local System' or extend ServicePage to capture a password.");
|
||||
|
||||
var objArg = ctx.ServiceAccount switch
|
||||
{
|
||||
"LocalSystem" => " obj= LocalSystem",
|
||||
"NetworkService" => " obj= \"NT AUTHORITY\\NetworkService\"",
|
||||
"LocalService" => " obj= \"NT AUTHORITY\\LocalService\"",
|
||||
_ => "",
|
||||
};
|
||||
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}{objArg}";
|
||||
|
||||
progress.Report("Creating service...");
|
||||
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
|
||||
if (exitCode == 1072)
|
||||
return StepResult.Fail(
|
||||
$"Service '{ServiceName}' is still marked for deletion. " +
|
||||
"Close services.msc / Task Manager / Event Viewer and retry, or reboot.");
|
||||
if (exitCode != 0)
|
||||
return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
|
||||
|
||||
// Configure restart policy
|
||||
var delay = ctx.RestartDelayMs;
|
||||
var failureArgs = $"failure {ServiceName} reset= 86400 actions= restart/{delay}/restart/{delay}/restart/{delay}";
|
||||
progress.Report("Configuring restart policy...");
|
||||
var (failExit, failOutput) = await RunSc(failureArgs, ctx, progress, ct);
|
||||
if (failExit != 0)
|
||||
progress.Report($"Warning: failed to set restart policy (exit {failExit})");
|
||||
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
private static async Task<(int ExitCode, string Output)> RunSc(
|
||||
string arguments, InstallContext ctx, IProgress<string> progress,
|
||||
CancellationToken ct, bool ignoreErrors = false)
|
||||
{
|
||||
var result = await ProcessRunner.RunAsync("sc.exe", arguments, null, progress, ct);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class StartServiceStep : IInstallStep
|
||||
{
|
||||
private const string ServiceName = StopServiceStep.ServiceName;
|
||||
|
||||
public string Name => "Start Worker Service";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report($"Starting {ServiceName}...");
|
||||
|
||||
var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
|
||||
// 1056 = ERROR_SERVICE_ALREADY_RUNNING — fine, fall through to the readiness poll.
|
||||
if (exit != 0 && exit != 1056)
|
||||
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
|
||||
|
||||
if (exit == 1056)
|
||||
progress.Report("Service was already running.");
|
||||
|
||||
// sc.exe start returns as soon as SCM accepts the command. Poll until the
|
||||
// service actually reports RUNNING so downstream steps and SignalR clients
|
||||
// don't race the worker's startup.
|
||||
progress.Report("Waiting for service to reach RUNNING state...");
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var (q, output) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
|
||||
if (q == 0 && output.Contains("RUNNING", StringComparison.OrdinalIgnoreCase))
|
||||
return StepResult.Ok();
|
||||
await Task.Delay(1000, ct);
|
||||
}
|
||||
|
||||
return StepResult.Fail("Service did not reach RUNNING state within 30 seconds.");
|
||||
}
|
||||
}
|
||||
19
src/ClaudeDo.Installer/Steps/StartWorkerStep.cs
Normal file
19
src/ClaudeDo.Installer/Steps/StartWorkerStep.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class StartWorkerStep : IInstallStep
|
||||
{
|
||||
public const string TaskName = "ClaudeDoWorker";
|
||||
|
||||
public string Name => "Start Worker";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report("Starting worker...");
|
||||
var (exit, output) = await ProcessRunner.RunAsync("schtasks.exe", $"/Run /TN \"{TaskName}\"", null, progress, ct);
|
||||
if (exit != 0)
|
||||
return StepResult.Fail($"schtasks /Run failed (exit {exit}): {output}");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class StopServiceStep : IInstallStep
|
||||
{
|
||||
public const string ServiceName = "ClaudeDoWorker";
|
||||
|
||||
public string Name => "Stop Worker Service";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report($"Stopping {ServiceName} (if running)...");
|
||||
|
||||
// sc.exe query -> returns non-zero if the service does not exist; that's fine.
|
||||
var (queryExit, queryOutput) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
|
||||
if (queryExit != 0)
|
||||
{
|
||||
progress.Report("Service is not registered — nothing to stop.");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
if (queryOutput.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
progress.Report("Service is already stopped.");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
|
||||
// 1062 = ERROR_SERVICE_NOT_ACTIVE — registered but not running, treat as already stopped.
|
||||
if (stopExit == 1062)
|
||||
{
|
||||
progress.Report("Service was registered but not running.");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
if (stopExit != 0)
|
||||
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");
|
||||
|
||||
// Poll until stopped or timeout (up to 30s).
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await Task.Delay(1000, ct);
|
||||
var (e, o) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
|
||||
if (e != 0 || o.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
progress.Report("Service stopped.");
|
||||
return StepResult.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
return StepResult.Fail("Service did not stop within 30 seconds.");
|
||||
}
|
||||
}
|
||||
48
src/ClaudeDo.Installer/Steps/StopWorkerStep.cs
Normal file
48
src/ClaudeDo.Installer/Steps/StopWorkerStep.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class StopWorkerStep : IInstallStep
|
||||
{
|
||||
public const string TaskName = "ClaudeDoWorker";
|
||||
public const string ProcessName = "ClaudeDo.Worker";
|
||||
|
||||
public string Name => "Stop Worker";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report("Stopping worker task (if running)...");
|
||||
await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct);
|
||||
|
||||
progress.Report("Stopping worker process (if running)...");
|
||||
var installDir = ctx.InstallDirectory;
|
||||
foreach (var p in Process.GetProcessesByName(ProcessName))
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = p.MainModule?.FileName;
|
||||
if (path is not null && !IsUnder(path, installDir)) continue;
|
||||
p.Kill(entireProcessTree: true);
|
||||
p.WaitForExit(10000);
|
||||
}
|
||||
catch { /* process may have exited or be inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
private static bool IsUnder(string filePath, string dir)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir)) return true; // can't scope — be permissive
|
||||
var full = Path.GetFullPath(filePath);
|
||||
var root = Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return full.StartsWith(root, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,22 @@ public sealed class WriteUninstallRegistryStep : IInstallStep
|
||||
// the single-file temp extract is gone once this process exits.
|
||||
var sourceExe = Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Cannot resolve running installer path.");
|
||||
try
|
||||
// In the self-update path the installer already runs from uninstaller/ (the
|
||||
// --replace-self handoff put it there), so source == target and the copy would
|
||||
// throw. Skip it; the binary is already in place.
|
||||
var alreadyInPlace = string.Equals(
|
||||
Path.GetFullPath(sourceExe), Path.GetFullPath(targetExe), StringComparison.OrdinalIgnoreCase);
|
||||
if (!alreadyInPlace)
|
||||
{
|
||||
progress.Report("Copying uninstaller binary...");
|
||||
File.Copy(sourceExe, targetExe, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
|
||||
try
|
||||
{
|
||||
progress.Report("Copying uninstaller binary...");
|
||||
File.Copy(sourceExe, targetExe, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
progress.Report("Writing Add/Remove Programs entry...");
|
||||
|
||||
@@ -11,8 +11,9 @@ public partial class SettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
private readonly IReleaseClient _releases;
|
||||
private readonly StopServiceStep _stopService;
|
||||
private readonly StartServiceStep _startService;
|
||||
private readonly StopWorkerStep _stopService;
|
||||
private readonly StartWorkerStep _startService;
|
||||
private readonly RegisterAutostartStep _registerAutostart;
|
||||
private readonly DownloadAndExtractStep _downloadStep;
|
||||
private readonly UninstallRunner _uninstallRunner;
|
||||
|
||||
@@ -37,8 +38,9 @@ public partial class SettingsViewModel : ObservableObject
|
||||
PageResolver resolver,
|
||||
InstallContext context,
|
||||
IReleaseClient releases,
|
||||
StopServiceStep stopService,
|
||||
StartServiceStep startService,
|
||||
StopWorkerStep stopService,
|
||||
StartWorkerStep startService,
|
||||
RegisterAutostartStep registerAutostart,
|
||||
DownloadAndExtractStep downloadStep,
|
||||
UninstallRunner uninstallRunner)
|
||||
{
|
||||
@@ -47,6 +49,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
_releases = releases;
|
||||
_stopService = stopService;
|
||||
_startService = startService;
|
||||
_registerAutostart = registerAutostart;
|
||||
_downloadStep = downloadStep;
|
||||
_uninstallRunner = uninstallRunner;
|
||||
_selectedPage = Pages.FirstOrDefault();
|
||||
@@ -154,7 +157,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
IsStatusError = false;
|
||||
|
||||
var progress = new Progress<string>(msg => StatusMessage = msg);
|
||||
var steps = new IInstallStep[] { _stopService, _downloadStep, _startService };
|
||||
var steps = new IInstallStep[] { _stopService, _downloadStep, _registerAutostart, _startService };
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
|
||||
@@ -6,13 +6,16 @@ public static class VersionComparer
|
||||
{
|
||||
public static VersionCompareResult Compare(string latest, string current)
|
||||
{
|
||||
var latestTrimmed = (latest ?? "").TrimStart('v', 'V');
|
||||
var currentTrimmed = (current ?? "").TrimStart('v', 'V');
|
||||
|
||||
var unparseable = !Version.TryParse(latestTrimmed, out var lv)
|
||||
| !Version.TryParse(currentTrimmed, out var cv);
|
||||
var unparseable = !Version.TryParse(CoreVersion(latest), out var lv)
|
||||
| !Version.TryParse(CoreVersion(current), out var cv);
|
||||
|
||||
if (unparseable) return new VersionCompareResult(false, true);
|
||||
return new VersionCompareResult(lv > cv, false);
|
||||
}
|
||||
|
||||
// Reduce a tag/version to its numeric core: drop a leading "v", MinVer build
|
||||
// metadata ("+sha"), and any SemVer prerelease suffix ("-alpha") — none of
|
||||
// which System.Version can parse. So "v1.0.2-alpha+abc" -> "1.0.2".
|
||||
private static string CoreVersion(string value)
|
||||
=> (value ?? "").TrimStart('v', 'V').Split('+')[0].Split('-')[0];
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ MVVM with CommunityToolkit.Mvvm source generators:
|
||||
- **TaskEditorView** — Modal dialog for task create/edit
|
||||
- **ListEditorView** — Modal dialog for list create/edit
|
||||
- **StatusBarView** — Connection status indicator, active task display
|
||||
- **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath. Opened via context menu or gear button on a list row.
|
||||
- **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath; also deletes the list (and its tasks) via a confirmed "Delete list" button. Opened via context menu or gear button on a list row.
|
||||
- **RepoImportModalView** — bulk-creates lists from git repos discovered under chosen parent folders. Opened via the folder button beside "New list" in the Lists island, or the "Add repos as lists…" Help-menu item. Repos already wired to a list show as disabled/"(already added)".
|
||||
- **DetailsIslandView** — contains an "Agent settings (overrides)" expander with per-task Model/SystemPrompt/AgentPath, showing inherited effective values. Disabled while task is running.
|
||||
|
||||
All views use compiled bindings (`x:DataType`).
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,8 +6,6 @@ namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public sealed class BoolToDraftOpacityConverter : IValueConverter
|
||||
{
|
||||
public static BoolToDraftOpacityConverter Instance { get; } = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is true ? 0.7 : 1.0;
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
|
||||
@@ -7,8 +7,6 @@ namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public sealed class BoolToItalicConverter : IValueConverter
|
||||
{
|
||||
public static BoolToItalicConverter Instance { get; } = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value is true ? FontStyle.Italic : FontStyle.Normal;
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
|
||||
@@ -7,17 +7,23 @@ namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public sealed class DiffLineKindToBrushConverter : IValueConverter
|
||||
{
|
||||
private static readonly ISolidColorBrush Added = new SolidColorBrush(Color.Parse("#66BB6A"));
|
||||
private static readonly ISolidColorBrush Removed = new SolidColorBrush(Color.Parse("#EF5350"));
|
||||
private static readonly ISolidColorBrush Hunk = new SolidColorBrush(Color.Parse("#42A5F5"));
|
||||
private static readonly ISolidColorBrush Header = new SolidColorBrush(Color.Parse("#9E9E9E"));
|
||||
private static readonly ISolidColorBrush Default = new SolidColorBrush(Color.Parse("#CFD8DC"));
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is WorktreeDiffLineKind kind
|
||||
? kind switch
|
||||
{
|
||||
WorktreeDiffLineKind.Added => new SolidColorBrush(Color.Parse("#66BB6A")),
|
||||
WorktreeDiffLineKind.Removed => new SolidColorBrush(Color.Parse("#EF5350")),
|
||||
WorktreeDiffLineKind.Hunk => new SolidColorBrush(Color.Parse("#42A5F5")),
|
||||
WorktreeDiffLineKind.Header => new SolidColorBrush(Color.Parse("#9E9E9E")),
|
||||
_ => new SolidColorBrush(Color.Parse("#CFD8DC")),
|
||||
WorktreeDiffLineKind.Added => Added,
|
||||
WorktreeDiffLineKind.Removed => Removed,
|
||||
WorktreeDiffLineKind.Hunk => Hunk,
|
||||
WorktreeDiffLineKind.Header => Header,
|
||||
_ => Default,
|
||||
}
|
||||
: new SolidColorBrush(Color.Parse("#CFD8DC"));
|
||||
: Default;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
@@ -7,17 +7,23 @@ namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public sealed class WorktreeStateColorConverter : IValueConverter
|
||||
{
|
||||
private static readonly ISolidColorBrush Active = new SolidColorBrush(Color.Parse("#42A5F5"));
|
||||
private static readonly ISolidColorBrush Merged = new SolidColorBrush(Color.Parse("#66BB6A"));
|
||||
private static readonly ISolidColorBrush Discarded = new SolidColorBrush(Color.Parse("#9E9E9E"));
|
||||
private static readonly ISolidColorBrush Kept = new SolidColorBrush(Color.Parse("#FFA726"));
|
||||
private static readonly ISolidColorBrush Default = new SolidColorBrush(Colors.Gray);
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is WorktreeState state
|
||||
? state switch
|
||||
{
|
||||
WorktreeState.Active => new SolidColorBrush(Color.Parse("#42A5F5")),
|
||||
WorktreeState.Merged => new SolidColorBrush(Color.Parse("#66BB6A")),
|
||||
WorktreeState.Discarded => new SolidColorBrush(Color.Parse("#9E9E9E")),
|
||||
WorktreeState.Kept => new SolidColorBrush(Color.Parse("#FFA726")),
|
||||
_ => new SolidColorBrush(Colors.Gray),
|
||||
WorktreeState.Active => Active,
|
||||
WorktreeState.Merged => Merged,
|
||||
WorktreeState.Discarded => Discarded,
|
||||
WorktreeState.Kept => Kept,
|
||||
_ => Default,
|
||||
}
|
||||
: new SolidColorBrush(Colors.Gray);
|
||||
: Default;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
61
src/ClaudeDo.Ui/Services/InstallArtifactLocator.cs
Normal file
61
src/ClaudeDo.Ui/Services/InstallArtifactLocator.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed class InstallerLocator : InstallArtifactLocator
|
||||
{
|
||||
protected override string Subdir => "uninstaller";
|
||||
protected override string ExeName => "ClaudeDo.Installer.exe";
|
||||
}
|
||||
|
||||
public sealed class WorkerLocator : InstallArtifactLocator
|
||||
{
|
||||
protected override string Subdir => "worker";
|
||||
protected override string ExeName => "ClaudeDo.Worker.exe";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locates an executable inside a ClaudeDo install: walk up from the running
|
||||
/// directory to the folder containing install.json, otherwise read the
|
||||
/// uninstall registry key. Subclasses supply the subdirectory and exe name.
|
||||
/// </summary>
|
||||
public abstract class InstallArtifactLocator
|
||||
{
|
||||
private const string InstallJson = "install.json";
|
||||
|
||||
protected abstract string Subdir { get; }
|
||||
protected abstract string ExeName { get; }
|
||||
|
||||
public string? Find()
|
||||
=> FindByWalkingUp(AppContext.BaseDirectory)
|
||||
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
|
||||
|
||||
public string? FindByWalkingUp(string startDir)
|
||||
{
|
||||
var dir = new DirectoryInfo(startDir);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, InstallJson)))
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, Subdir, ExeName);
|
||||
return File.Exists(candidate) ? candidate : null;
|
||||
}
|
||||
dir = dir.Parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
public string? FindByRegistry()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return null;
|
||||
try
|
||||
{
|
||||
using var key = Microsoft.Win32.Registry.LocalMachine
|
||||
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
|
||||
var location = key?.GetValue("InstallLocation") as string;
|
||||
if (string.IsNullOrEmpty(location)) return null;
|
||||
var candidate = Path.Combine(location, Subdir, ExeName);
|
||||
return File.Exists(candidate) ? candidate : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed class InstallerLocator
|
||||
{
|
||||
private const string InstallJson = "install.json";
|
||||
private const string InstallerExe = "ClaudeDo.Installer.exe";
|
||||
private const string UninstallerSubdir = "uninstaller";
|
||||
|
||||
public string? Find()
|
||||
=> FindByWalkingUp(AppContext.BaseDirectory) ?? FindByRegistry();
|
||||
|
||||
public string? FindByWalkingUp(string startDir)
|
||||
{
|
||||
var dir = new DirectoryInfo(startDir);
|
||||
while (dir is not null)
|
||||
{
|
||||
var manifest = Path.Combine(dir.FullName, InstallJson);
|
||||
if (File.Exists(manifest))
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, UninstallerSubdir, InstallerExe);
|
||||
return File.Exists(candidate) ? candidate : null;
|
||||
}
|
||||
dir = dir.Parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
public string? FindByRegistry()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var key = Microsoft.Win32.Registry.LocalMachine
|
||||
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
|
||||
var location = key?.GetValue("InstallLocation") as string;
|
||||
if (string.IsNullOrEmpty(location)) return null;
|
||||
var candidate = Path.Combine(location, UninstallerSubdir, InstallerExe);
|
||||
return File.Exists(candidate) ? candidate : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/ClaudeDo.Ui/Services/Interfaces/IPrimeScheduleApi.cs
Normal file
8
src/ClaudeDo.Ui/Services/Interfaces/IPrimeScheduleApi.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public interface IPrimeScheduleApi
|
||||
{
|
||||
Task<List<PrimeScheduleDto>> ListAsync();
|
||||
Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto);
|
||||
Task DeleteAsync(Guid id);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// <summary>Raised once when the SignalR connection is first established, and again on every reconnect.</summary>
|
||||
event Action? ConnectionRestoredEvent;
|
||||
event Action<string>? WorktreeUpdatedEvent;
|
||||
event Action<string>? ListUpdatedEvent;
|
||||
event Action<string, string>? TaskMessageEvent;
|
||||
|
||||
event Action<string, string>? PlanningMergeStartedEvent;
|
||||
26
src/ClaudeDo.Ui/Services/RepoScanner.cs
Normal file
26
src/ClaudeDo.Ui/Services/RepoScanner.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed record RepoCandidate(string Name, string FullPath);
|
||||
|
||||
public static class RepoScanner
|
||||
{
|
||||
public static IReadOnlyList<RepoCandidate> Scan(string parentFolder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parentFolder) || !Directory.Exists(parentFolder))
|
||||
return Array.Empty<RepoCandidate>();
|
||||
|
||||
var result = new List<RepoCandidate>();
|
||||
IEnumerable<string> subdirs;
|
||||
try { subdirs = Directory.EnumerateDirectories(parentFolder); }
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||
{ return Array.Empty<RepoCandidate>(); }
|
||||
|
||||
foreach (var dir in subdirs)
|
||||
{
|
||||
var gitPath = Path.Combine(dir, ".git");
|
||||
if (Directory.Exists(gitPath) || File.Exists(gitPath))
|
||||
result.Add(new RepoCandidate(Path.GetFileName(dir), dir));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<string>? TaskUpdatedEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string>? RunNowRequestedEvent;
|
||||
public event Action<string>? ListUpdatedEvent;
|
||||
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
||||
|
||||
@@ -139,7 +138,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
|
||||
_hub.On<string, WorkerLogLevel, DateTime>("WorkerLog", (message, level, timestampUtc) =>
|
||||
{
|
||||
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc));
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc)));
|
||||
});
|
||||
|
||||
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
|
||||
@@ -225,9 +225,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
try { await _hub.StopAsync(); } catch { /* swallow */ }
|
||||
}
|
||||
|
||||
/// <summary>Invoke a hub method, returning default (null) when the worker is offline or errors.</summary>
|
||||
private async Task<T?> TryInvokeAsync<T>(string method, params object?[] args)
|
||||
{
|
||||
try { return await _hub.InvokeCoreAsync<T>(method, args); }
|
||||
catch { return default; }
|
||||
}
|
||||
|
||||
public async Task RunNowAsync(string taskId)
|
||||
{
|
||||
RunNowRequestedEvent?.Invoke(taskId);
|
||||
await _hub.InvokeAsync("RunNow", taskId);
|
||||
}
|
||||
|
||||
@@ -247,17 +253,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
|
||||
}
|
||||
|
||||
public async Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
|
||||
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
|
||||
|
||||
public async Task CancelTaskAsync(string taskId)
|
||||
{
|
||||
@@ -270,34 +267,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
}
|
||||
|
||||
public async Task<List<AgentInfo>> GetAgentsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var agents = await _hub.InvokeAsync<List<AgentInfo>>("GetAgents");
|
||||
return agents ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
=> await TryInvokeAsync<List<AgentInfo>>("GetAgents") ?? [];
|
||||
|
||||
public async Task RefreshAgentsAsync()
|
||||
{
|
||||
await _hub.InvokeAsync("RefreshAgents");
|
||||
}
|
||||
|
||||
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public Task<SeedResultDto?> RestoreDefaultAgentsAsync()
|
||||
=> TryInvokeAsync<SeedResultDto>("RestoreDefaultAgents");
|
||||
|
||||
private async Task SeedActiveTasksAsync()
|
||||
{
|
||||
@@ -328,17 +306,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.DisposeAsync();
|
||||
}
|
||||
|
||||
public async Task<AppSettingsDto?> GetAppSettingsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<AppSettingsDto>("GetAppSettings");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public Task<AppSettingsDto?> GetAppSettingsAsync()
|
||||
=> TryInvokeAsync<AppSettingsDto>("GetAppSettings");
|
||||
|
||||
public async Task UpdateAppSettingsAsync(AppSettingsDto dto)
|
||||
{
|
||||
@@ -346,16 +315,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
}
|
||||
|
||||
public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
|
||||
{
|
||||
try { return await _hub.InvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules"); }
|
||||
catch { return new List<PrimeScheduleDto>(); }
|
||||
}
|
||||
=> await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>();
|
||||
|
||||
public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
|
||||
{
|
||||
try { return await _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto); }
|
||||
catch { return null; }
|
||||
}
|
||||
public Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
|
||||
=> TryInvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto);
|
||||
|
||||
public async Task DeletePrimeScheduleAsync(Guid id)
|
||||
{
|
||||
@@ -373,17 +336,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("UpdateListConfig", dto);
|
||||
}
|
||||
|
||||
public async Task<ListConfigDto?> GetListConfigAsync(string listId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<ListConfigDto?>("GetListConfig", listId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public Task<ListConfigDto?> GetListConfigAsync(string listId)
|
||||
=> TryInvokeAsync<ListConfigDto>("GetListConfig", listId);
|
||||
|
||||
public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto)
|
||||
{
|
||||
@@ -395,66 +349,35 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
||||
}
|
||||
|
||||
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
|
||||
=> TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
|
||||
|
||||
public async Task<WorktreeResetDto?> ResetAllWorktreesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public Task<WorktreeResetDto?> ResetAllWorktreesAsync()
|
||||
=> TryInvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
|
||||
|
||||
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
|
||||
=> await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId)
|
||||
?? new List<WorktreeOverviewDto>();
|
||||
|
||||
public async Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rows = await _hub.InvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId);
|
||||
return rows ?? new List<WorktreeOverviewDto>();
|
||||
var ok = await _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState);
|
||||
return (ok, null);
|
||||
}
|
||||
catch
|
||||
catch (HubException ex)
|
||||
{
|
||||
return new List<WorktreeOverviewDto>();
|
||||
return (false, ex.Message);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return (false, "Worker offline.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SetWorktreeStateAsync(string taskId, WorktreeState newState)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
|
||||
=> TryInvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
|
||||
|
||||
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
|
||||
@@ -475,29 +398,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
=> await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _hub.InvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId);
|
||||
return result ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
=> await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? [];
|
||||
|
||||
public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
|
||||
=> TryInvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
|
||||
|
||||
public async Task MergeAllPlanningAsync(string planningTaskId, string targetBranch)
|
||||
{
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
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;
|
||||
@@ -13,6 +13,37 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg }
|
||||
|
||||
public sealed class LogLineViewModel
|
||||
{
|
||||
public required LogKind Kind { get; init; }
|
||||
public required string Text { get; init; }
|
||||
public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss");
|
||||
public string KindMarker => Kind switch
|
||||
{
|
||||
LogKind.Sys => "sys",
|
||||
LogKind.Tool => "tool",
|
||||
LogKind.Claude => "claude",
|
||||
LogKind.Stdout => "out",
|
||||
LogKind.Stderr => "err",
|
||||
LogKind.Done => "done",
|
||||
LogKind.Msg => "claude",
|
||||
_ => "",
|
||||
};
|
||||
public string ClassName => Kind switch
|
||||
{
|
||||
LogKind.Sys => "log-sys",
|
||||
LogKind.Tool => "log-tool",
|
||||
LogKind.Claude => "log-claude",
|
||||
LogKind.Stdout => "log-stdout",
|
||||
LogKind.Stderr => "log-stderr",
|
||||
LogKind.Done => "log-done",
|
||||
LogKind.Msg => "log-msg",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
|
||||
public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
@@ -24,6 +55,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ReviewCombinedDiffCommand))]
|
||||
[NotifyPropertyChangedFor(nameof(TaskIdBadge))]
|
||||
private TaskRowViewModel? _task;
|
||||
|
||||
// Editable fields
|
||||
@@ -31,8 +64,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
[ObservableProperty] private string _editableDescription = "";
|
||||
[ObservableProperty] private bool _isEditingDescription;
|
||||
[ObservableProperty] private bool _isDescriptionExpanded = true;
|
||||
[ObservableProperty] private string _notes = "";
|
||||
[ObservableProperty] private string _promptInput = "";
|
||||
|
||||
public bool IsDescriptionEditorVisible => IsDescriptionExpanded && IsEditingDescription;
|
||||
public bool IsDescriptionPreviewVisible => IsDescriptionExpanded && !IsEditingDescription;
|
||||
@@ -175,10 +206,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
// Set by the view so OpenDiffCommand can show the modal as a dialog
|
||||
public Func<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; }
|
||||
|
||||
// Set by the view so OpenWorktreeCommand can show the modal as a dialog
|
||||
public Func<WorktreeModalViewModel, System.Threading.Tasks.Task>? ShowWorktreeModal { get; set; }
|
||||
|
||||
// Set by the view so ApproveMergeCommand can show the modal as a dialog
|
||||
// Set by the view so OpenDiff can pass through merge requests from the diff modal
|
||||
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
|
||||
|
||||
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
||||
@@ -223,7 +251,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
DequeueCommand.NotifyCanExecuteChanged();
|
||||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||
ContinueCommand.NotifyCanExecuteChanged();
|
||||
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -427,7 +454,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
_subscribedTaskId = null;
|
||||
EditableTitle = "";
|
||||
EditableDescription = "";
|
||||
Notes = "";
|
||||
Model = null;
|
||||
WorktreePath = null;
|
||||
WorktreeStateLabel = null;
|
||||
@@ -473,7 +499,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
_suppressDescSave = true;
|
||||
try { EditableDescription = entity.Description ?? ""; }
|
||||
finally { _suppressDescSave = false; }
|
||||
Notes = entity.Notes ?? "";
|
||||
Model = entity.Model;
|
||||
WorktreePath = entity.Worktree?.Path;
|
||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||
@@ -737,29 +762,55 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
{
|
||||
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||||
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnWorktreeStateLabelChanged(string? value)
|
||||
{
|
||||
ApproveMergeCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task SendPromptAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PromptInput) || Task == null) return;
|
||||
Log.Add(new LogLineViewModel { Kind = LogKind.Msg, Text = $"[you] {PromptInput}" });
|
||||
// TODO: WorkerClient has no SendPromptAsync — no matching hub method found.
|
||||
// When the worker gains a "SendPrompt" hub method, call:
|
||||
// await _worker.SendPromptAsync(Task.Id, PromptInput);
|
||||
PromptInput = "";
|
||||
await System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseDetails() => CloseDetail?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task ToggleStarAsync()
|
||||
{
|
||||
if (Task is null) return;
|
||||
Task.IsStarred = !Task.IsStarred;
|
||||
await using var ctx = _dbFactory.CreateDbContext();
|
||||
var repo = new TaskRepository(ctx);
|
||||
var entity = await repo.GetByIdAsync(Task.Id);
|
||||
if (entity is null) return;
|
||||
entity.IsStarred = Task.IsStarred;
|
||||
await repo.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task ToggleDoneAsync()
|
||||
{
|
||||
if (Task is null) return;
|
||||
Task.Done = !Task.Done;
|
||||
await using var ctx = _dbFactory.CreateDbContext();
|
||||
var repo = new TaskRepository(ctx);
|
||||
var entity = await repo.GetByIdAsync(Task.Id);
|
||||
if (entity is null) return;
|
||||
entity.Status = Task.Done
|
||||
? ClaudeDo.Data.Models.TaskStatus.Done
|
||||
: ClaudeDo.Data.Models.TaskStatus.Idle;
|
||||
Task.Status = entity.Status;
|
||||
AgentStatusLabel = entity.Status.ToString();
|
||||
await repo.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task ToggleSubtaskDoneAsync(SubtaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
row.Done = !row.Done;
|
||||
await using var ctx = _dbFactory.CreateDbContext();
|
||||
var repo = new SubtaskRepository(ctx);
|
||||
var subs = await repo.GetByTaskIdAsync(Task?.Id ?? "");
|
||||
var entity = subs.FirstOrDefault(s => s.Id == row.Id);
|
||||
if (entity is null) return;
|
||||
entity.Completed = row.Done;
|
||||
await repo.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task DeleteTaskAsync()
|
||||
{
|
||||
@@ -813,30 +864,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
NewSubtaskTitle = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task SaveNotesAsync()
|
||||
{
|
||||
if (Task == null) return;
|
||||
await using var ctx = _dbFactory.CreateDbContext();
|
||||
var repo = new TaskRepository(ctx);
|
||||
var entity = await repo.GetByIdAsync(Task.Id);
|
||||
if (entity == null) return;
|
||||
entity.Notes = Notes;
|
||||
await repo.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanMerge))]
|
||||
private async System.Threading.Tasks.Task ApproveMergeAsync()
|
||||
{
|
||||
if (Task == null || ShowMergeModal == null) return;
|
||||
var vm = _services.GetRequiredService<MergeModalViewModel>();
|
||||
await vm.InitializeAsync(Task.Id, Task.Title);
|
||||
await ShowMergeModal(vm);
|
||||
}
|
||||
|
||||
private bool CanMerge() =>
|
||||
Task != null && _worker.IsConnected && WorktreePath != null && WorktreeStateLabel == "Active";
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task StopAsync()
|
||||
{
|
||||
@@ -857,7 +884,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
private bool CanEnqueue() =>
|
||||
Task != null && _worker.IsConnected && IsIdle;
|
||||
Task != null && _worker.IsConnected && IsIdle
|
||||
&& (!Task.IsChild || Task.ParentFinalized);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanDequeue))]
|
||||
private async System.Threading.Tasks.Task DequeueAsync()
|
||||
|
||||
@@ -29,6 +29,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
|
||||
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }
|
||||
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
|
||||
public Func<RepoImportModalViewModel, System.Threading.Tasks.Task>? ShowRepoImportModal { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenSettings()
|
||||
@@ -47,19 +48,38 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType);
|
||||
await ShowListSettingsModal(vm);
|
||||
await RefreshRowAsync(row.Id);
|
||||
if (vm.Deleted) await LoadAsync();
|
||||
else await RefreshRowAsync(row.Id);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task OpenRepoImportAsync()
|
||||
{
|
||||
if (ShowRepoImportModal is null || _services is null) return;
|
||||
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
|
||||
await vm.LoadAsync();
|
||||
await ShowRepoImportModal(vm);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private bool _worktreesOverviewOpen;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenWorktreesOverviewAsync(ListNavItemViewModel? row)
|
||||
{
|
||||
if (row is null || ShowWorktreesOverviewModal is null || _services is null) return;
|
||||
if (row.Kind != ListKind.User) return;
|
||||
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
|
||||
vm.Configure(rawId, row.Name);
|
||||
await vm.LoadAsync();
|
||||
await ShowWorktreesOverviewModal(vm);
|
||||
if (_worktreesOverviewOpen) return;
|
||||
_worktreesOverviewOpen = true;
|
||||
try
|
||||
{
|
||||
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
|
||||
vm.Configure(rawId, row.Name);
|
||||
await vm.LoadAsync();
|
||||
await ShowWorktreesOverviewModal(vm);
|
||||
}
|
||||
finally { _worktreesOverviewOpen = false; }
|
||||
}
|
||||
|
||||
public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
|
||||
@@ -91,6 +111,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
|
||||
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
|
||||
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
|
||||
_worker.WorktreeUpdatedEvent += _id => _ = RefreshCountsAsync();
|
||||
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
|
||||
}
|
||||
}
|
||||
@@ -205,7 +226,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType);
|
||||
await ShowListSettingsModal(vm);
|
||||
await RefreshRowAsync(item.Id);
|
||||
if (vm.Deleted) await LoadAsync();
|
||||
else await RefreshRowAsync(item.Id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg }
|
||||
|
||||
public sealed class LogLineViewModel
|
||||
{
|
||||
public required LogKind Kind { get; init; }
|
||||
public required string Text { get; init; }
|
||||
public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss");
|
||||
public string KindMarker => Kind switch
|
||||
{
|
||||
LogKind.Sys => "sys",
|
||||
LogKind.Tool => "tool",
|
||||
LogKind.Claude => "claude",
|
||||
LogKind.Stdout => "out",
|
||||
LogKind.Stderr => "err",
|
||||
LogKind.Done => "done",
|
||||
LogKind.Msg => "claude",
|
||||
_ => "",
|
||||
};
|
||||
public string ClassName => Kind switch
|
||||
{
|
||||
LogKind.Sys => "log-sys",
|
||||
LogKind.Tool => "log-tool",
|
||||
LogKind.Claude => "log-claude",
|
||||
LogKind.Stdout => "log-stdout",
|
||||
LogKind.Stderr => "log-stderr",
|
||||
LogKind.Done => "log-done",
|
||||
LogKind.Msg => "log-msg",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _isExpanded = true;
|
||||
[ObservableProperty] private bool _hasPlanningChildren;
|
||||
[ObservableProperty] private bool _hasQueuedSubtasks;
|
||||
[ObservableProperty] private bool _showListChip = true;
|
||||
[ObservableProperty] private bool _parentFinalized;
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
||||
@@ -38,7 +40,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
||||
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|
||||
|| HasPlanningChildren;
|
||||
public bool IsDraft => IsChild && Status == TaskStatus.Idle;
|
||||
// A subtask is Draft until its planning parent is finalized, then Planned (queueable).
|
||||
public bool IsDraft => IsChild && Status == TaskStatus.Idle && !ParentFinalized;
|
||||
public bool IsPlanned => IsChild && Status == TaskStatus.Idle && ParentFinalized;
|
||||
|
||||
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
|
||||
&& PlanningPhase == PlanningPhase.None
|
||||
@@ -52,6 +56,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public bool IsPlanActive => PlanningPhase == PlanningPhase.Active;
|
||||
public bool IsPlanFinalized => PlanningPhase == PlanningPhase.Finalized;
|
||||
|
||||
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
|
||||
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
|
||||
public bool HasSteps => StepsCount > 0;
|
||||
@@ -60,7 +67,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
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 CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks;
|
||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks
|
||||
&& (!IsChild || ParentFinalized);
|
||||
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
||||
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
||||
&& PlanningPhase == PlanningPhase.Finalized
|
||||
&& !HasQueuedSubtasks;
|
||||
public bool HasSchedule => ScheduledFor.HasValue;
|
||||
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
||||
|
||||
@@ -86,23 +98,44 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(IsWaiting));
|
||||
OnPropertyChanged(nameof(HasLiveTail));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(IsPlanned));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
}
|
||||
|
||||
partial void OnParentTaskIdChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsChild));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(IsPlanned));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
}
|
||||
|
||||
partial void OnParentFinalizedChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(IsPlanned));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
}
|
||||
|
||||
partial void OnPlanningPhaseChanged(PlanningPhase value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsPlanningParent));
|
||||
OnPropertyChanged(nameof(PlanningBadge));
|
||||
OnPropertyChanged(nameof(IsPlanActive));
|
||||
OnPropertyChanged(nameof(IsPlanFinalized));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
||||
OnPropertyChanged(nameof(CanQueuePlan));
|
||||
}
|
||||
|
||||
partial void OnHasQueuedSubtasksChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
OnPropertyChanged(nameof(CanQueuePlan));
|
||||
}
|
||||
|
||||
partial void OnBlockedByTaskIdChanged(string? value)
|
||||
@@ -112,14 +145,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(StatusChipClass));
|
||||
}
|
||||
|
||||
partial void OnParentTaskIdChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsChild));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
}
|
||||
|
||||
partial void OnHasPlanningChildrenChanged(bool value)
|
||||
=> OnPropertyChanged(nameof(IsPlanningParent));
|
||||
{
|
||||
OnPropertyChanged(nameof(IsPlanningParent));
|
||||
OnPropertyChanged(nameof(CanQueuePlan));
|
||||
}
|
||||
|
||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||||
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
|
||||
|
||||
@@ -57,6 +57,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.TaskMessageEvent += OnWorkerTaskMessage;
|
||||
_worker.ListUpdatedEvent += OnWorkerListUpdated;
|
||||
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +68,29 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
if (row is not null) row.LiveTail = line;
|
||||
}
|
||||
|
||||
private async void OnWorkerListUpdated(string listId)
|
||||
{
|
||||
// Mirror the renamed list onto every task row that references it,
|
||||
// so the per-row ListName chip on virtual lists stays current.
|
||||
try
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
|
||||
if (entity is null) return;
|
||||
var visibleIds = Items.Select(r => r.Id).ToHashSet();
|
||||
if (visibleIds.Count == 0) return;
|
||||
var matchingIds = await db.Tasks.AsNoTracking()
|
||||
.Where(t => t.ListId == listId && visibleIds.Contains(t.Id))
|
||||
.Select(t => t.Id)
|
||||
.ToListAsync();
|
||||
var matching = matchingIds.ToHashSet();
|
||||
foreach (var row in Items)
|
||||
if (matching.Contains(row.Id) && row.ListName != entity.Name)
|
||||
row.ListName = entity.Name;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async void OnWorkerTaskUpdated(string taskId)
|
||||
{
|
||||
var list = _currentList;
|
||||
@@ -192,8 +216,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
filteredList.Add(c);
|
||||
}
|
||||
|
||||
var showListChip = list.Kind == ListKind.Virtual;
|
||||
foreach (var t in filteredList)
|
||||
Items.Add(TaskRowViewModel.FromEntity(t));
|
||||
{
|
||||
var row = TaskRowViewModel.FromEntity(t);
|
||||
row.ShowListChip = showListChip;
|
||||
Items.Add(row);
|
||||
}
|
||||
|
||||
// Mark any top-level row that has at least one child as a planning parent,
|
||||
// so its subtasks remain expandable even after the parent is queued/running.
|
||||
@@ -215,6 +244,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
foreach (var r in Items)
|
||||
r.HasQueuedSubtasks = parentsWithQueuedKids.Contains(r.Id);
|
||||
|
||||
// A subtask is "Planned" (queueable) once its planning parent is finalized;
|
||||
// until then it is a "Draft".
|
||||
var finalizedParents = Items
|
||||
.Where(r => r.PlanningPhase == PlanningPhase.Finalized)
|
||||
.Select(r => r.Id)
|
||||
.ToHashSet();
|
||||
foreach (var r in Items)
|
||||
r.ParentFinalized = !string.IsNullOrEmpty(r.ParentTaskId)
|
||||
&& finalizedParents.Contains(r.ParentTaskId!);
|
||||
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
}
|
||||
@@ -342,6 +381,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
db.Tasks.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
var row = TaskRowViewModel.FromEntity(entity);
|
||||
row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
|
||||
Items.Add(row);
|
||||
Regroup();
|
||||
NewTaskTitle = "";
|
||||
@@ -615,7 +655,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
await _worker.ResumePlanningSessionAsync(row.Id);
|
||||
break;
|
||||
case UnfinishedPlanningModalResult.FinalizeNow:
|
||||
await _worker.FinalizePlanningSessionAsync(row.Id);
|
||||
await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false);
|
||||
break;
|
||||
case UnfinishedPlanningModalResult.Discard:
|
||||
await TryDiscardPlanningWithRetryAsync(row.Id);
|
||||
@@ -683,7 +723,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true); }
|
||||
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
|
||||
@@ -29,10 +29,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
|
||||
public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
|
||||
|
||||
private readonly UpdateCheckService _updateCheck;
|
||||
private readonly InstallerLocator _installerLocator;
|
||||
private readonly UpdateCheckService _updateCheck = null!;
|
||||
private readonly InstallerLocator _installerLocator = null!;
|
||||
private readonly WorkerLocator _workerLocator = null!;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
||||
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
|
||||
private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
|
||||
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
|
||||
|
||||
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
|
||||
|
||||
// Set by MainWindow to open the conflict resolution dialog.
|
||||
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
|
||||
@@ -40,6 +45,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
// Set by MainWindow to open the About dialog.
|
||||
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
|
||||
|
||||
// Set by MainWindow to open the repo-import dialog.
|
||||
public Func<RepoImportModalViewModel, Task>? ShowRepoImportModal { get; set; }
|
||||
|
||||
// Set by MainWindow to open the global worktrees overview dialog.
|
||||
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
|
||||
|
||||
@@ -163,14 +171,20 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
WorkerClient worker,
|
||||
UpdateCheckService updateCheck,
|
||||
InstallerLocator installerLocator,
|
||||
WorkerLocator workerLocator,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory)
|
||||
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
|
||||
Func<MergeModalViewModel> mergeVmFactory,
|
||||
Func<RepoImportModalViewModel> repoImportVmFactory)
|
||||
{
|
||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||
_updateCheck = updateCheck;
|
||||
_installerLocator = installerLocator;
|
||||
_workerLocator = workerLocator;
|
||||
_dbFactory = dbFactory;
|
||||
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
|
||||
_mergeVmFactory = mergeVmFactory;
|
||||
_repoImportVmFactory = repoImportVmFactory;
|
||||
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
||||
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||
@@ -207,6 +221,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
_primeStatusTimer.Elapsed += (_, _) =>
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() => PrimeStatus = null);
|
||||
_ = Lists.LoadAsync();
|
||||
_ = EnsureWorkerRunningAsync();
|
||||
_updateCheck.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus))
|
||||
@@ -255,14 +270,31 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
if (ShowAboutModal is not null) await ShowAboutModal(vm);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenRepoImport()
|
||||
{
|
||||
if (ShowRepoImportModal is null || _repoImportVmFactory is null) return;
|
||||
var vm = _repoImportVmFactory();
|
||||
await vm.LoadAsync();
|
||||
await ShowRepoImportModal(vm);
|
||||
if (Lists is not null) await Lists.LoadAsync();
|
||||
}
|
||||
|
||||
private bool _worktreesOverviewOpen;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenWorktreesOverviewGlobalAsync()
|
||||
{
|
||||
if (ShowWorktreesOverviewModal is null) return;
|
||||
var vm = _worktreesOverviewVmFactory();
|
||||
vm.Configure(null, null);
|
||||
await vm.LoadAsync();
|
||||
await ShowWorktreesOverviewModal(vm);
|
||||
if (ShowWorktreesOverviewModal is null || _worktreesOverviewOpen) return;
|
||||
_worktreesOverviewOpen = true;
|
||||
try
|
||||
{
|
||||
var vm = _worktreesOverviewVmFactory();
|
||||
vm.Configure(null, null);
|
||||
await vm.LoadAsync();
|
||||
await ShowWorktreesOverviewModal(vm);
|
||||
}
|
||||
finally { _worktreesOverviewOpen = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -273,42 +305,60 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
|
||||
[ObservableProperty] private string? _restartWorkerStatus;
|
||||
|
||||
private bool _ensureRunningAttempted;
|
||||
|
||||
private async Task EnsureWorkerRunningAsync()
|
||||
{
|
||||
if (_ensureRunningAttempted) return;
|
||||
_ensureRunningAttempted = true;
|
||||
await Task.Delay(TimeSpan.FromSeconds(4));
|
||||
if (Worker?.IsConnected == true) return;
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is null) return;
|
||||
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); }
|
||||
catch { /* logon task is the primary mechanism; this is a convenience */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestartWorkerAsync()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
await FlashRestartStatusAsync("Service control is Windows-only.");
|
||||
return;
|
||||
}
|
||||
|
||||
RestartWorkerStatus = "Restarting worker…";
|
||||
try
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
using var sc = new System.ServiceProcess.ServiceController("ClaudeDoWorker");
|
||||
if (sc.Status != System.ServiceProcess.ServiceControllerStatus.Stopped)
|
||||
{
|
||||
sc.Stop();
|
||||
sc.WaitForStatus(System.ServiceProcess.ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(20));
|
||||
}
|
||||
sc.Start();
|
||||
sc.WaitForStatus(System.ServiceProcess.ServiceControllerStatus.Running, TimeSpan.FromSeconds(20));
|
||||
});
|
||||
await Task.Run(RestartWorkerService);
|
||||
await FlashRestartStatusAsync("Worker restarted.");
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// ServiceController throws this when the service is not installed.
|
||||
await FlashRestartStatusAsync("ClaudeDoWorker service is not installed.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await FlashRestartStatusAsync($"Restart failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void RestartWorkerService()
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
|
||||
|
||||
// Only kill the worker belonging to THIS installation — not any other
|
||||
// ClaudeDo.Worker on the machine (e.g. a second install).
|
||||
var exeFull = System.IO.Path.GetFullPath(exe);
|
||||
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = p.MainModule?.FileName;
|
||||
if (path is not null &&
|
||||
!string.Equals(System.IO.Path.GetFullPath(path), exeFull, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
p.Kill(entireProcessTree: true);
|
||||
p.WaitForExit(10000);
|
||||
}
|
||||
catch { /* may have exited or be inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
private async Task FlashRestartStatusAsync(string text)
|
||||
{
|
||||
RestartWorkerStatus = text;
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
|
||||
public string ListId { get; set; } = "";
|
||||
|
||||
// True after the list was deleted, so the caller reloads the list nav instead of refreshing the row.
|
||||
public bool Deleted { get; private set; }
|
||||
|
||||
// Wired by the view to prompt yes/no before deleting and to surface a blocking-FK error.
|
||||
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
||||
public Func<string, Task>? ShowErrorAsync { get; set; }
|
||||
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _workingDir = "";
|
||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||
@@ -29,9 +40,10 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public ListSettingsModalViewModel(WorkerClient worker)
|
||||
public ListSettingsModalViewModel(WorkerClient worker, IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
{
|
||||
_worker = worker;
|
||||
_dbFactory = dbFactory;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(
|
||||
@@ -78,6 +90,37 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
var displayName = string.IsNullOrWhiteSpace(Name) ? "Untitled" : Name;
|
||||
if (ConfirmAsync is not null)
|
||||
{
|
||||
var ok = await ConfirmAsync($"Delete list \"{displayName}\" and all its tasks? This cannot be undone.");
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var lists = new ListRepository(ctx);
|
||||
await lists.DeleteAsync(ListId);
|
||||
}
|
||||
catch (Exception ex) when (
|
||||
(ex is Microsoft.Data.Sqlite.SqliteException
|
||||
|| ex.InnerException is Microsoft.Data.Sqlite.SqliteException)
|
||||
&& (ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|
||||
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true))
|
||||
{
|
||||
if (ShowErrorAsync is not null)
|
||||
await ShowErrorAsync("This list has planning sessions with child tasks. Discard those first, then delete the list.");
|
||||
return;
|
||||
}
|
||||
|
||||
Deleted = true;
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel() => CloseAction?.Invoke();
|
||||
|
||||
|
||||
@@ -14,15 +14,15 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
|
||||
public ObservableCollection<string> Branches { get; } = new();
|
||||
|
||||
[ObservableProperty] private string? _selectedBranch;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private string? _selectedBranch;
|
||||
[ObservableProperty] private bool _removeWorktree = true;
|
||||
[ObservableProperty] private string _commitMessage = "";
|
||||
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _isBusy;
|
||||
[ObservableProperty] private string? _errorMessage;
|
||||
[ObservableProperty] private string? _warningMessage;
|
||||
[ObservableProperty] private string? _successMessage;
|
||||
[ObservableProperty] private bool _hasConflict;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _hasConflict;
|
||||
[ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
206
src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs
Normal file
206
src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class RepoImportItemViewModel : ViewModelBase
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public string FullPath { get; init; } = "";
|
||||
|
||||
// True when a list already points at this path. Such rows are shown ticked + disabled.
|
||||
public bool AlreadyAdded { get; init; }
|
||||
public bool CanToggle => !AlreadyAdded;
|
||||
|
||||
[ObservableProperty] private bool _isChecked;
|
||||
|
||||
// Driven by the search filter; the row collapses when it doesn't match.
|
||||
[ObservableProperty] private bool _isVisible = true;
|
||||
}
|
||||
|
||||
public sealed partial class RepoImportModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<string> _folders = new();
|
||||
|
||||
public ObservableCollection<RepoImportItemViewModel> Repos { get; } = new();
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
[ObservableProperty] private string _searchText = "";
|
||||
|
||||
public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded);
|
||||
public bool CanCreate => CreateCount > 0;
|
||||
public string CreateButtonText => $"Create {CreateCount} list(s)";
|
||||
public bool HasFolders => _folders.Count > 0;
|
||||
|
||||
public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
ClearRepos();
|
||||
_existingDirs.Clear();
|
||||
_folders.Clear();
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var lists = new ListRepository(ctx);
|
||||
foreach (var l in await lists.GetAllAsync(ct))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(l.WorkingDir))
|
||||
_existingDirs.Add(l.WorkingDir!);
|
||||
}
|
||||
|
||||
var settings = new AppSettingsRepository(ctx);
|
||||
foreach (var f in await settings.GetRepoImportFoldersAsync(ct))
|
||||
AddFolderToSet(f);
|
||||
|
||||
ScanAndAdd(_folders);
|
||||
OnPropertyChanged(nameof(HasFolders));
|
||||
NotifyCreateState();
|
||||
}
|
||||
|
||||
public async Task AddFoldersAsync(IEnumerable<string> folders)
|
||||
{
|
||||
var added = new List<string>();
|
||||
foreach (var f in folders)
|
||||
if (AddFolderToSet(f)) added.Add(f);
|
||||
|
||||
if (added.Count == 0) return;
|
||||
|
||||
ScanAndAdd(added);
|
||||
OnPropertyChanged(nameof(HasFolders));
|
||||
NotifyCreateState();
|
||||
await SaveFoldersAsync();
|
||||
}
|
||||
|
||||
private void ScanAndAdd(IEnumerable<string> folders)
|
||||
{
|
||||
var current = new HashSet<string>(
|
||||
Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
foreach (var item in BuildCandidates(RepoScanner.Scan(folder), current, _existingDirs))
|
||||
{
|
||||
item.PropertyChanged += OnItemChanged;
|
||||
Repos.Add(item);
|
||||
current.Add(item.FullPath);
|
||||
}
|
||||
}
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
public static List<RepoImportItemViewModel> BuildCandidates(
|
||||
IEnumerable<RepoCandidate> found,
|
||||
IReadOnlySet<string> currentPaths,
|
||||
IReadOnlySet<string> existingDirs)
|
||||
{
|
||||
var items = new List<RepoImportItemViewModel>();
|
||||
foreach (var c in found)
|
||||
{
|
||||
if (currentPaths.Contains(c.FullPath)) continue;
|
||||
var alreadyAdded = existingDirs.Contains(c.FullPath);
|
||||
items.Add(new RepoImportItemViewModel
|
||||
{
|
||||
Name = c.Name,
|
||||
FullPath = c.FullPath,
|
||||
AlreadyAdded = alreadyAdded,
|
||||
// New repos start unchecked (no auto-select); already-added rows show ticked + disabled.
|
||||
IsChecked = alreadyAdded,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ForgetFoldersAsync()
|
||||
{
|
||||
_folders.Clear();
|
||||
ClearRepos();
|
||||
OnPropertyChanged(nameof(HasFolders));
|
||||
NotifyCreateState();
|
||||
await SaveFoldersAsync();
|
||||
}
|
||||
|
||||
partial void OnSearchTextChanged(string value) => ApplyFilter();
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
var q = SearchText?.Trim() ?? "";
|
||||
foreach (var r in Repos)
|
||||
r.IsVisible = q.Length == 0
|
||||
|| r.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| r.FullPath.Contains(q, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private bool AddFolderToSet(string folder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(folder)) return false;
|
||||
if (_folders.Any(f => string.Equals(f, folder, StringComparison.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
_folders.Add(folder);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task SaveFoldersAsync()
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
await new AppSettingsRepository(ctx).SetRepoImportFoldersAsync(_folders);
|
||||
}
|
||||
|
||||
private void ClearRepos()
|
||||
{
|
||||
foreach (var r in Repos) r.PropertyChanged -= OnItemChanged;
|
||||
Repos.Clear();
|
||||
}
|
||||
|
||||
private void OnItemChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(RepoImportItemViewModel.IsChecked))
|
||||
NotifyCreateState();
|
||||
}
|
||||
|
||||
private void NotifyCreateState()
|
||||
{
|
||||
OnPropertyChanged(nameof(CreateCount));
|
||||
OnPropertyChanged(nameof(CanCreate));
|
||||
OnPropertyChanged(nameof(CreateButtonText));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
var toCreate = Repos.Where(r => r.IsChecked && !r.AlreadyAdded).ToList();
|
||||
if (toCreate.Count > 0)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var lists = new ListRepository(ctx);
|
||||
foreach (var r in toCreate)
|
||||
{
|
||||
await lists.AddAsync(new ListEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Name = r.Name,
|
||||
WorkingDir = r.FullPath,
|
||||
DefaultCommitType = CommitTypeRegistry.DefaultType,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel() => CloseAction?.Invoke();
|
||||
}
|
||||
@@ -15,15 +15,15 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string _taskId = "";
|
||||
[ObservableProperty] private string _taskTitle = "";
|
||||
[ObservableProperty] private TaskStatus _taskStatus;
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(IsRunning))] private TaskStatus _taskStatus;
|
||||
[ObservableProperty] private string _listId = "";
|
||||
[ObservableProperty] private string _listName = "";
|
||||
[ObservableProperty] private string _path = "";
|
||||
[ObservableProperty] private string _branchName = "";
|
||||
[ObservableProperty] private string _baseCommit = "";
|
||||
[ObservableProperty] private WorktreeState _state;
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(IsActive))] private WorktreeState _state;
|
||||
[ObservableProperty] private string? _diffStat;
|
||||
[ObservableProperty] private DateTime _createdAt;
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt;
|
||||
[ObservableProperty] private bool _pathExistsOnDisk;
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
|
||||
@@ -66,6 +66,8 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
|
||||
public Action<string, string>? JumpToTaskAction { get; set; }
|
||||
public Func<string, Task<bool>>? ConfirmAction { get; set; }
|
||||
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
|
||||
public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; }
|
||||
|
||||
public WorktreesOverviewModalViewModel(WorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory)
|
||||
{
|
||||
@@ -103,7 +105,8 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
Groups.Clear();
|
||||
if (IsGlobal)
|
||||
{
|
||||
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName)).OrderBy(g => g.Key.ListName))
|
||||
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName))
|
||||
.OrderBy(g => g.Key.ListName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName };
|
||||
foreach (var row in grp) group.Rows.Add(row);
|
||||
@@ -158,10 +161,21 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
private void OpenInExplorer(WorktreeOverviewRowViewModel? row)
|
||||
{
|
||||
if (row is null || !row.PathExistsOnDisk) return;
|
||||
try { Process.Start(new ProcessStartInfo { FileName = "explorer.exe", Arguments = $"\"{row.Path}\"", UseShellExecute = true }); }
|
||||
try { Process.Start(new ProcessStartInfo { FileName = row.Path, UseShellExecute = true }); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Merge(WorktreeOverviewRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.State != WorktreeState.Active) return;
|
||||
if (ResolveMergeVm is null || ShowMergeAction is null) return;
|
||||
var mergeVm = ResolveMergeVm();
|
||||
await mergeVm.InitializeAsync(row.TaskId, row.TaskTitle);
|
||||
await ShowMergeAction(mergeVm);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void JumpToTask(WorktreeOverviewRowViewModel? row)
|
||||
{
|
||||
@@ -174,16 +188,18 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
private async Task Discard(WorktreeOverviewRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.State != WorktreeState.Active) return;
|
||||
if (await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Discarded))
|
||||
row.State = WorktreeState.Discarded;
|
||||
var (ok, err) = await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Discarded);
|
||||
if (ok) row.State = WorktreeState.Discarded;
|
||||
else StatusMessage = err ?? "Failed to discard worktree.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Keep(WorktreeOverviewRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.State != WorktreeState.Active) return;
|
||||
if (await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Kept))
|
||||
row.State = WorktreeState.Kept;
|
||||
var (ok, err) = await _worker.SetWorktreeStateAsync(row.TaskId, WorktreeState.Kept);
|
||||
if (ok) row.State = WorktreeState.Kept;
|
||||
else StatusMessage = err ?? "Failed to keep worktree.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
Margin="0,0,8,0"/>
|
||||
<TextBox Grid.Column="1" x:Name="TimeInput"
|
||||
Watermark="HH:mm" MaxLength="5"
|
||||
PlaceholderText="HH:mm" MaxLength="5"
|
||||
Text="{Binding #Root.TimeText, Mode=TwoWay}"/>
|
||||
<Button Grid.Column="2" Content="Done"
|
||||
Click="OnDoneClick"
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-btn"
|
||||
ToolTip.Tip="Copy path"
|
||||
Click="OnCopyWorktreePathClick"
|
||||
VerticalAlignment="Center">
|
||||
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands;
|
||||
|
||||
public partial class AgentStripView : UserControl
|
||||
{
|
||||
public AgentStripView() { InitializeComponent(); }
|
||||
|
||||
private async void OnCopyWorktreePathClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not DetailsIslandViewModel vm) return;
|
||||
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (clipboard is null || string.IsNullOrEmpty(vm.WorktreePath)) return;
|
||||
await clipboard.SetTextAsync(vm.WorktreePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,13 +38,16 @@
|
||||
<!-- ── Header (sticky top): check · eyebrow · title · status · star · gear ── -->
|
||||
<Border DockPanel.Dock="Top" Classes="island-header">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<Ellipse Grid.Column="0"
|
||||
Classes="task-check"
|
||||
Classes.done="{Binding Task.Done}"
|
||||
Width="18" Height="18"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,2,10,0"
|
||||
Cursor="Hand"/>
|
||||
<Button Grid.Column="0" Classes="flat"
|
||||
Command="{Binding ToggleDoneCommand}"
|
||||
Padding="0"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,2,10,0">
|
||||
<Ellipse Classes="task-check"
|
||||
Classes.done="{Binding Task.Done}"
|
||||
Width="18" Height="18"
|
||||
Cursor="Hand"/>
|
||||
</Button>
|
||||
<StackPanel Grid.Column="1" Spacing="0">
|
||||
<TextBlock Text="{Binding TaskIdBadge}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||
@@ -62,6 +65,8 @@
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-btn star-btn"
|
||||
Classes.on="{Binding Task.IsStarred}"
|
||||
Command="{Binding ToggleStarCommand}"
|
||||
ToolTip.Tip="Star"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
<PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
|
||||
@@ -176,13 +181,17 @@
|
||||
<Border Classes="subtask-row"
|
||||
Classes.done="{Binding Done}">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Ellipse Grid.Column="0"
|
||||
Classes="task-check"
|
||||
Classes.done="{Binding Done}"
|
||||
Width="16" Height="16"
|
||||
VerticalAlignment="Center"
|
||||
Cursor="Hand"
|
||||
Margin="0,0,8,0"/>
|
||||
<Button Grid.Column="0" Classes="flat"
|
||||
Padding="0"
|
||||
Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).ToggleSubtaskDoneCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Ellipse Classes="task-check"
|
||||
Classes.done="{Binding Done}"
|
||||
Width="16" Height="16"
|
||||
Cursor="Hand"/>
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1"
|
||||
Classes="subtask-title"
|
||||
Text="{Binding Title}"
|
||||
|
||||
@@ -30,14 +30,6 @@ public partial class DetailsIslandView : UserControl
|
||||
await modal.ShowDialog(owner);
|
||||
};
|
||||
|
||||
vm.ShowWorktreeModal = async (worktreeVm) =>
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner == null) return;
|
||||
var modal = new WorktreeModalView { DataContext = worktreeVm };
|
||||
await modal.ShowDialog(owner);
|
||||
};
|
||||
|
||||
vm.ShowMergeModal = async (mergeVm) =>
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
@@ -143,12 +135,6 @@ public partial class DetailsIslandView : UserControl
|
||||
return await tcs.Task;
|
||||
}
|
||||
|
||||
private void NotesLostFocus(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is DetailsIslandViewModel vm)
|
||||
vm.SaveNotesCommand.Execute(null);
|
||||
}
|
||||
|
||||
private async void OnCopyDescriptionClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not DetailsIslandViewModel vm) return;
|
||||
|
||||
@@ -168,19 +168,28 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- + New list button -->
|
||||
<Button Classes="new-list-btn" Margin="0,4,0,0"
|
||||
Command="{Binding CreateListCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<PathIcon Data="{StaticResource Icon.Plus}"
|
||||
Width="13" Height="13"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="New list" FontSize="12"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!-- New list + import row -->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="new-list-btn"
|
||||
Command="{Binding CreateListCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<PathIcon Data="{StaticResource Icon.Plus}"
|
||||
Width="13" Height="13"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="New list" FontSize="12"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Grid.Column="1" Classes="icon-btn" Margin="6,0,0,0"
|
||||
Command="{Binding OpenRepoImportCommand}"
|
||||
ToolTip.Tip="Add repos as lists">
|
||||
<PathIcon Data="{StaticResource Icon.Folder}"
|
||||
Width="14" Height="14"
|
||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using ClaudeDo.Ui.Views.Modals;
|
||||
@@ -22,32 +26,50 @@ public partial class ListsIslandView : UserControl
|
||||
{
|
||||
var window = new ListSettingsModalView { DataContext = modal };
|
||||
modal.CloseAction = () => window.Close();
|
||||
modal.ConfirmAsync = ShowConfirmAsync;
|
||||
modal.ShowErrorAsync = ShowErrorDialogAsync;
|
||||
var top = TopLevel.GetTopLevel(this) as Window;
|
||||
if (top is null) window.Show();
|
||||
else await window.ShowDialog(top);
|
||||
};
|
||||
vm.ShowRepoImportModal = async modal =>
|
||||
{
|
||||
var window = new RepoImportModalView { DataContext = modal };
|
||||
modal.CloseAction = () => window.Close();
|
||||
var top = TopLevel.GetTopLevel(this) as Window;
|
||||
if (top is null) window.Show();
|
||||
else await window.ShowDialog(top);
|
||||
};
|
||||
vm.ShowWorktreesOverviewModal = async modal =>
|
||||
{
|
||||
var top = TopLevel.GetTopLevel(this) as Window;
|
||||
var shell = top?.DataContext as IslandsShellViewModel;
|
||||
var window = new WorktreesOverviewModalView { DataContext = modal };
|
||||
modal.CloseAction = () => window.Close();
|
||||
modal.JumpToTaskAction = (listId, _) =>
|
||||
modal.JumpToTaskAction = (listId, taskId) =>
|
||||
{
|
||||
if (vm is { } v)
|
||||
{
|
||||
var item = v.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
|
||||
if (item is not null) v.SelectedList = item;
|
||||
}
|
||||
if (shell is not null)
|
||||
_ = JumpToTaskAsync(shell, listId, taskId);
|
||||
};
|
||||
modal.ShowDiffAction = diffVm =>
|
||||
{
|
||||
var top2 = TopLevel.GetTopLevel(this) as Window;
|
||||
if (top2 is null) return;
|
||||
if (top is null) return;
|
||||
var dlg = new WorktreeModalView { DataContext = diffVm };
|
||||
diffVm.CloseAction = () => dlg.Close();
|
||||
_ = diffVm.LoadAsync();
|
||||
_ = dlg.ShowDialog(top2);
|
||||
_ = dlg.ShowDialog(top);
|
||||
};
|
||||
var top = TopLevel.GetTopLevel(this) as Window;
|
||||
modal.ConfirmAction = ShowConfirmAsync;
|
||||
if (shell is not null)
|
||||
{
|
||||
modal.ResolveMergeVm = shell.ResolveMergeVm;
|
||||
modal.ShowMergeAction = async mergeVm =>
|
||||
{
|
||||
if (top is null) return;
|
||||
var mergeDlg = new MergeModalView { DataContext = mergeVm };
|
||||
await mergeDlg.ShowDialog(top);
|
||||
};
|
||||
}
|
||||
if (top is null) window.Show();
|
||||
else await window.ShowDialog(top);
|
||||
};
|
||||
@@ -69,4 +91,88 @@ public partial class ListsIslandView : UserControl
|
||||
var modal = new SettingsModalView { DataContext = settingsVm };
|
||||
await modal.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private static System.Threading.Tasks.Task JumpToTaskAsync(IslandsShellViewModel s, string listId, string taskId)
|
||||
=> JumpToTaskHelper.SelectAsync(s, listId, taskId);
|
||||
|
||||
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return;
|
||||
|
||||
var ok = new Button { Content = "OK", MinWidth = 90 };
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Error",
|
||||
Width = 360,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ShowInTaskbar = false,
|
||||
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 16,
|
||||
Margin = new Thickness(20),
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Children = { ok },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
ok.Click += (_, _) => dialog.Close();
|
||||
await dialog.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return false;
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Confirm",
|
||||
Width = 380,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ShowInTaskbar = false,
|
||||
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(20),
|
||||
Spacing = 16,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Spacing = 8,
|
||||
Children = { cancel, confirm },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
|
||||
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
|
||||
dialog.Closed += (_, _) => tcs.TrySetResult(false);
|
||||
|
||||
_ = dialog.ShowDialog(owner);
|
||||
return await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||
<MenuItem Header="Queue subtasks sequentially"
|
||||
Click="OnQueuePlanningSubtasksClick"
|
||||
IsVisible="{Binding HasPlanningChildren}"/>
|
||||
IsVisible="{Binding CanQueuePlan}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
|
||||
<MenuItem Header="Clear schedule"
|
||||
@@ -113,7 +113,13 @@
|
||||
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
|
||||
<TextBlock Text="DRAFT"/>
|
||||
</Border>
|
||||
<Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}">
|
||||
<Border Classes="badge planned" IsVisible="{Binding IsPlanned}">
|
||||
<TextBlock Text="PLANNED"/>
|
||||
</Border>
|
||||
<Border Classes="badge"
|
||||
Classes.planning="{Binding IsPlanActive}"
|
||||
Classes.planned="{Binding IsPlanFinalized}"
|
||||
IsVisible="{Binding IsPlanningParent}">
|
||||
<TextBlock Text="{Binding PlanningBadge}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
@@ -141,7 +147,7 @@
|
||||
</Button>
|
||||
|
||||
<!-- List chip with dot -->
|
||||
<Border Classes="chip chip-list">
|
||||
<Border Classes="chip chip-list" IsVisible="{Binding ShowListChip}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
||||
<Ellipse Width="6" Height="6"
|
||||
Fill="{DynamicResource MossBrush}"
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
Foreground="{DynamicResource TextFaintBrush}"/>
|
||||
</Border>
|
||||
<TextBox Grid.Column="1" x:Name="AddTaskBox" Classes="add-task-input"
|
||||
Watermark="Add a task…"
|
||||
PlaceholderText="Add a task…"
|
||||
Text="{Binding NewTaskTitle, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="12,0,0,0">
|
||||
|
||||
42
src/ClaudeDo.Ui/Views/JumpToTaskHelper.cs
Normal file
42
src/ClaudeDo.Ui/Views/JumpToTaskHelper.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
namespace ClaudeDo.Ui.Views;
|
||||
|
||||
internal static class JumpToTaskHelper
|
||||
{
|
||||
// Selects the list, then waits for the matching task row to appear in Tasks.Items.
|
||||
// Listens to CollectionChanged so it works regardless of how long LoadForListAsync takes;
|
||||
// bounded by a hard timeout so we never hang if the task is filtered out.
|
||||
public static async Task SelectAsync(IslandsShellViewModel s, string listId, string taskId)
|
||||
{
|
||||
if (s.Lists is null || s.Tasks is null) return;
|
||||
var item = s.Lists.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
|
||||
if (item is null) return;
|
||||
s.Lists.SelectedList = item;
|
||||
|
||||
var existing = s.Tasks.Items.FirstOrDefault(r => r.Id == taskId);
|
||||
if (existing is not null) { s.Tasks.SelectedTask = existing; return; }
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
void OnChanged(object? _, NotifyCollectionChangedEventArgs __)
|
||||
{
|
||||
var row = s.Tasks!.Items.FirstOrDefault(r => r.Id == taskId);
|
||||
if (row is not null) tcs.TrySetResult(true);
|
||||
}
|
||||
|
||||
s.Tasks.Items.CollectionChanged += OnChanged;
|
||||
try
|
||||
{
|
||||
await Task.WhenAny(tcs.Task, Task.Delay(5000));
|
||||
var row = s.Tasks.Items.FirstOrDefault(r => r.Id == taskId);
|
||||
if (row is not null) s.Tasks.SelectedTask = row;
|
||||
}
|
||||
finally
|
||||
{
|
||||
s.Tasks.Items.CollectionChanged -= OnChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
Background="{DynamicResource VoidBrush}"
|
||||
Icon="avares://ClaudeDo.Ui/Assets/ClaudeTask.ico"
|
||||
CanResize="True"
|
||||
SystemDecorations="BorderOnly"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1">
|
||||
<Window.Resources>
|
||||
@@ -72,6 +72,7 @@
|
||||
<MenuItem Header="Worktrees…"
|
||||
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
|
||||
<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>
|
||||
<MenuItem Header="Add repos as lists…" Command="{Binding OpenRepoImportCommand}"/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</StackPanel>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
@@ -38,13 +41,10 @@ public partial class MainWindow : Window
|
||||
{
|
||||
var dlg = new WorktreesOverviewModalView { DataContext = modal };
|
||||
modal.CloseAction = () => dlg.Close();
|
||||
modal.JumpToTaskAction = (listId, _) =>
|
||||
modal.JumpToTaskAction = (listId, taskId) =>
|
||||
{
|
||||
if (DataContext is IslandsShellViewModel s)
|
||||
{
|
||||
var item = s.Lists?.UserLists.FirstOrDefault(l => l.Id == $"user:{listId}");
|
||||
if (item is not null && s.Lists is not null) s.Lists.SelectedList = item;
|
||||
}
|
||||
_ = JumpToTaskAsync(s, listId, taskId);
|
||||
};
|
||||
modal.ShowDiffAction = diffVm =>
|
||||
{
|
||||
@@ -53,6 +53,19 @@ public partial class MainWindow : Window
|
||||
_ = diffVm.LoadAsync();
|
||||
_ = diffDlg.ShowDialog(this);
|
||||
};
|
||||
modal.ConfirmAction = ShowConfirmAsync;
|
||||
modal.ResolveMergeVm = vm.ResolveMergeVm;
|
||||
modal.ShowMergeAction = async mergeVm =>
|
||||
{
|
||||
var mergeDlg = new MergeModalView { DataContext = mergeVm };
|
||||
await mergeDlg.ShowDialog(this);
|
||||
};
|
||||
await dlg.ShowDialog(this);
|
||||
};
|
||||
vm.ShowRepoImportModal = async (modal) =>
|
||||
{
|
||||
var dlg = new RepoImportModalView { DataContext = modal };
|
||||
modal.CloseAction = () => dlg.Close();
|
||||
await dlg.ShowDialog(this);
|
||||
};
|
||||
}
|
||||
@@ -85,4 +98,48 @@ public partial class MainWindow : Window
|
||||
base.OnSizeChanged(e);
|
||||
if (DataContext is IslandsShellViewModel vm) vm.WindowWidth = Bounds.Width;
|
||||
}
|
||||
|
||||
private static System.Threading.Tasks.Task JumpToTaskAsync(IslandsShellViewModel s, string listId, string taskId)
|
||||
=> JumpToTaskHelper.SelectAsync(s, listId, taskId);
|
||||
|
||||
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Confirm",
|
||||
Width = 380,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ShowInTaskbar = false,
|
||||
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(20),
|
||||
Spacing = 16,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Spacing = 8,
|
||||
Children = { cancel, confirm },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
|
||||
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
|
||||
dialog.Closed += (_, _) => tcs.TrySetResult(false);
|
||||
|
||||
_ = dialog.ShowDialog(this);
|
||||
return await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
x:Class="ClaudeDo.Ui.Views.Modals.AboutModalView"
|
||||
x:DataType="vm:AboutModalViewModel"
|
||||
Title="About ClaudeDo"
|
||||
Width="480" Height="280"
|
||||
SystemDecorations="None"
|
||||
Width="620" Height="280"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
@@ -23,8 +23,8 @@
|
||||
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">
|
||||
<ScrollViewer Grid.Row="1" Padding="20,16" HorizontalScrollBarVisibility="Disabled">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="10" ColumnSpacing="8">
|
||||
<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"/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
x:DataType="vm:DiffModalViewModel"
|
||||
Title="Diff"
|
||||
Width="1200" Height="800"
|
||||
SystemDecorations="None"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource SurfaceBrush}">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
Width="520" Height="720"
|
||||
CanResize="True"
|
||||
MinWidth="460" MinHeight="520"
|
||||
SystemDecorations="None"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
@@ -42,6 +42,10 @@
|
||||
<Setter Property="Foreground" Value="{DynamicResource DeepBrush}"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
<Style Selector="Button.danger">
|
||||
<Setter Property="Background" Value="{DynamicResource BloodBrush}"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Border Background="{DynamicResource SurfaceBrush}"
|
||||
@@ -89,7 +93,7 @@
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Working directory"/>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" Watermark="(none)" />
|
||||
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" PlaceholderText="(none)" />
|
||||
<Button Grid.Column="1" Content="Browse..." Margin="8,0,0,0" Click="BrowseClicked" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
@@ -168,12 +172,14 @@
|
||||
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="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
|
||||
<Button Content="Save" Classes="primary" Command="{Binding SaveCommand}" MinWidth="90"/>
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center" Margin="16,0">
|
||||
<Button Grid.Column="0" Content="Delete list" Classes="danger"
|
||||
Command="{Binding DeleteCommand}" MinWidth="90"/>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
|
||||
<Button Content="Save" Classes="primary" Command="{Binding SaveCommand}" MinWidth="90"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
Title="Merge worktree"
|
||||
Width="560" Height="460"
|
||||
CanResize="False"
|
||||
SystemDecorations="None"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
|
||||
83
src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml
Normal file
83
src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml
Normal file
@@ -0,0 +1,83 @@
|
||||
<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.RepoImportModalView"
|
||||
x:DataType="vm:RepoImportModalViewModel"
|
||||
Title="Add repos as lists"
|
||||
Width="560" Height="480"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1">
|
||||
<Grid RowDefinitions="36,Auto,*,52">
|
||||
|
||||
<!-- Header -->
|
||||
<Border Grid.Row="0" Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
|
||||
<TextBlock Text="ADD REPOS AS LISTS" 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 CancelCommand}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Toolbar: search + folder actions -->
|
||||
<StackPanel Grid.Row="1" Spacing="8" Margin="16,12,16,6">
|
||||
<TextBox Text="{Binding SearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||
PlaceholderText="Search repos…"/>
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Button Grid.Column="0" Content="Add folder…" Click="AddFolderClicked"/>
|
||||
<Button Grid.Column="2" Content="Forget folders"
|
||||
Command="{Binding ForgetFoldersCommand}"
|
||||
IsVisible="{Binding HasFolders}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Repo checklist -->
|
||||
<ScrollViewer Grid.Row="2" Padding="16,2,16,8">
|
||||
<ItemsControl ItemsSource="{Binding Repos}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:RepoImportItemViewModel">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,1"
|
||||
IsVisible="{Binding IsVisible}">
|
||||
<CheckBox Grid.Column="0" MinWidth="0"
|
||||
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
||||
IsEnabled="{Binding CanToggle}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Name}"
|
||||
Foreground="{DynamicResource TextBrush}" FontSize="12"
|
||||
VerticalAlignment="Center" Margin="4,0,0,0"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding FullPath}"
|
||||
Foreground="{DynamicResource TextFaintBrush}"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="10"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
<TextBlock Grid.Column="3" Text="(already added)"
|
||||
Foreground="{DynamicResource TextFaintBrush}" FontSize="10"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0"
|
||||
IsVisible="{Binding AlreadyAdded}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Footer -->
|
||||
<Border Grid.Row="3" 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="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
|
||||
<Button Content="{Binding CreateButtonText}" Command="{Binding CreateCommand}"
|
||||
IsEnabled="{Binding CanCreate}" MinWidth="120" Classes="primary"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
30
src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs
Normal file
30
src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Modals;
|
||||
|
||||
public partial class RepoImportModalView : Window
|
||||
{
|
||||
public RepoImportModalView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private async void AddFolderClicked(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not RepoImportModalViewModel vm) return;
|
||||
var top = TopLevel.GetTopLevel(this);
|
||||
if (top is null) return;
|
||||
|
||||
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Choose folders containing repos",
|
||||
AllowMultiple = true,
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
|
||||
await vm.AddFoldersAsync(folders.Select(f => f.Path.LocalPath));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
x:DataType="vm:SettingsModalViewModel"
|
||||
Title="Settings"
|
||||
Width="580" Height="760"
|
||||
SystemDecorations="None"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
@@ -94,7 +94,7 @@
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Default instructions"/>
|
||||
<TextBox AcceptsReturn="True" TextWrapping="Wrap" Height="110"
|
||||
Watermark="Baseline instructions applied to every task"
|
||||
PlaceholderText="Baseline instructions applied to every task"
|
||||
Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,12,*,12,*">
|
||||
@@ -133,7 +133,7 @@
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Central worktree root"/>
|
||||
<TextBox Text="{Binding Worktrees.CentralWorktreeRoot, Mode=TwoWay}"
|
||||
Watermark="e.g. C:\worktrees"/>
|
||||
PlaceholderText="e.g. C:\worktrees"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
Title="Unfinished planning session"
|
||||
Width="440" Height="200"
|
||||
CanResize="False"
|
||||
SystemDecorations="None"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
Width="1100" Height="720"
|
||||
MinWidth="640" MinHeight="400"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
SystemDecorations="BorderOnly"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
Background="Transparent"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
CanResize="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}"
|
||||
SystemDecorations="BorderOnly"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1">
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).JumpToTaskCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Merge…"
|
||||
IsEnabled="{Binding IsActive}"
|
||||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).MergeCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<MenuItem Header="Discard"
|
||||
IsEnabled="{Binding IsActive}"
|
||||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).DiscardCommand}"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
x:Class="ClaudeDo.Ui.Views.Planning.ConflictResolutionView"
|
||||
Title="Merge conflict"
|
||||
Width="560" SizeToContent="Height"
|
||||
SystemDecorations="None"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource SurfaceBrush}">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
x:DataType="vm:PlanningDiffViewModel"
|
||||
Title="Planning — Combined diff"
|
||||
Width="1100" Height="700"
|
||||
SystemDecorations="None"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource SurfaceBrush}">
|
||||
|
||||
@@ -17,6 +17,8 @@ Worker/
|
||||
Hub/ — WorkerHub, HubBroadcaster
|
||||
```
|
||||
|
||||
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
|
||||
@@ -24,7 +26,14 @@ Worker/
|
||||
- **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.
|
||||
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern:
|
||||
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `RunTaskNow`, `CancelTask`, `DeleteTask`
|
||||
- `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList`
|
||||
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `SetTaskConfig`
|
||||
- `RunHistoryMcpTools` — `ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
|
||||
- `AgentMcpTools` — `ListAgents`
|
||||
- `LifecycleMcpTools` — `ResetFailedTask`
|
||||
- `AppSettingsMcpTools` — `GetAppSettings` (read-only)
|
||||
|
||||
## Status Model
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
<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="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
@@ -23,6 +24,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
31
src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs
vendored
Normal file
31
src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record AppSettingsReadDto(
|
||||
string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode,
|
||||
string WorktreeStrategy, string? CentralWorktreeRoot,
|
||||
bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class AppSettingsMcpTools
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
|
||||
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
|
||||
|
||||
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")]
|
||||
public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
|
||||
{
|
||||
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var row = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
|
||||
return new AppSettingsReadDto(
|
||||
row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode,
|
||||
row.WorktreeStrategy, row.CentralWorktreeRoot,
|
||||
row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays);
|
||||
}
|
||||
}
|
||||
64
src/ClaudeDo.Worker/External/ConfigMcpTools.cs
vendored
Normal file
64
src/ClaudeDo.Worker/External/ConfigMcpTools.cs
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class ConfigMcpTools
|
||||
{
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
|
||||
public ConfigMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster)
|
||||
{
|
||||
_lists = lists;
|
||||
_tasks = tasks;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns null if no config is set.")]
|
||||
public async Task<TaskConfigDto?> GetListConfig(string listId, CancellationToken cancellationToken)
|
||||
{
|
||||
var cfg = await _lists.GetConfigAsync(listId, cancellationToken);
|
||||
return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Set a list's default model/system prompt/agent path. Passing all three as null clears the list config.")]
|
||||
public async Task SetListConfig(
|
||||
string listId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
|
||||
var m = model.NullIfBlank();
|
||||
var sp = systemPrompt.NullIfBlank();
|
||||
var ap = agentPath.NullIfBlank();
|
||||
|
||||
if (m is null && sp is null && ap is null)
|
||||
await _lists.DeleteConfigAsync(listId, cancellationToken);
|
||||
else
|
||||
await _lists.SetConfigAsync(new ListConfigEntity
|
||||
{
|
||||
ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap,
|
||||
}, cancellationToken);
|
||||
|
||||
await _broadcaster.ListUpdated(listId);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path). Pass null for any field to clear that override.")]
|
||||
public async Task SetTaskConfig(
|
||||
string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), cancellationToken);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
}
|
||||
45
src/ClaudeDo.Worker/External/LifecycleMcpTools.cs
vendored
Normal file
45
src/ClaudeDo.Worker/External/LifecycleMcpTools.cs
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Agents;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ModelContextProtocol.Server;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class AgentMcpTools
|
||||
{
|
||||
private readonly AgentFileService _agents;
|
||||
|
||||
public AgentMcpTools(AgentFileService agents) => _agents = agents;
|
||||
|
||||
[McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")]
|
||||
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
|
||||
=> await _agents.ScanAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class LifecycleMcpTools
|
||||
{
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly TaskResetService _reset;
|
||||
|
||||
public LifecycleMcpTools(TaskRepository tasks, TaskResetService reset)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_reset = reset;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted.")]
|
||||
public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status != TaskStatus.Failed)
|
||||
throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool.");
|
||||
|
||||
await _reset.ResetAsync(taskId, cancellationToken);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user