feat(worker): run worker as per-user logon task instead of Windows service
A LocalSystem Windows service can't see the logged-in user's Claude CLI authentication, so the worker now runs as the current user via a hidden per-user logon Scheduled Task with restart-on-failure. - Worker is WinExe (no console window) with a Serilog rolling file sink and a single-instance mutex so the logon task, app ensure-running, and Restart button can't fight over the SignalR port. - Installer replaces the service steps (register/start/stop) with autostart task steps, migrates the legacy ClaudeDoWorker service away on update, and removes the task on uninstall. ServicePage drops the service-account UI. - UI gains a WorkerLocator; the app ensures the worker is running at startup and the Restart button kills+relaunches this install's worker process. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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>();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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...");
|
||||
|
||||
@@ -42,20 +42,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 +119,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
|
||||
|
||||
@@ -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"
|
||||
<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"/>
|
||||
</StackPanel>
|
||||
|
||||
<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,6 +26,13 @@ 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.");
|
||||
// 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)
|
||||
{
|
||||
try
|
||||
{
|
||||
progress.Report("Copying uninstaller binary...");
|
||||
@@ -35,6 +42,7 @@ public sealed class WriteUninstallRegistryStep : IInstallStep
|
||||
{
|
||||
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
progress.Report("Writing Add/Remove Programs entry...");
|
||||
try
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
43
src/ClaudeDo.Ui/Services/WorkerLocator.cs
Normal file
43
src/ClaudeDo.Ui/Services/WorkerLocator.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
|
||||
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!;
|
||||
@@ -170,6 +171,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
WorkerClient worker,
|
||||
UpdateCheckService updateCheck,
|
||||
InstallerLocator installerLocator,
|
||||
WorkerLocator workerLocator,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
|
||||
Func<MergeModalViewModel> mergeVmFactory,
|
||||
@@ -178,6 +180,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||
_updateCheck = updateCheck;
|
||||
_installerLocator = installerLocator;
|
||||
_workerLocator = workerLocator;
|
||||
_dbFactory = dbFactory;
|
||||
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
|
||||
_mergeVmFactory = mergeVmFactory;
|
||||
@@ -218,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))
|
||||
@@ -301,43 +305,58 @@ 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(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}");
|
||||
}
|
||||
}
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
private static void RestartWorkerService()
|
||||
private void RestartWorkerService()
|
||||
{
|
||||
using var sc = new System.ServiceProcess.ServiceController("ClaudeDoWorker");
|
||||
if (sc.Status != System.ServiceProcess.ServiceControllerStatus.Stopped)
|
||||
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"))
|
||||
{
|
||||
sc.Stop();
|
||||
sc.WaitForStatus(System.ServiceProcess.ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(20));
|
||||
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);
|
||||
}
|
||||
sc.Start();
|
||||
sc.WaitForStatus(System.ServiceProcess.ServiceControllerStatus.Running, TimeSpan.FromSeconds(20));
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Threading;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
@@ -13,14 +14,28 @@ using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.Prime;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
|
||||
// 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
|
||||
|
||||
var cfg = WorkerConfig.Load();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// When launched by the Windows SCM, speak the Service Control Protocol so SCM
|
||||
// doesn't think we crashed (~30s timeout). No-op when running interactively.
|
||||
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
|
||||
var logRoot = 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));
|
||||
|
||||
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
||||
opt.UseSqlite($"Data Source={cfg.DbPath}"));
|
||||
@@ -209,3 +224,5 @@ else
|
||||
{
|
||||
await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
|
||||
}
|
||||
|
||||
GC.KeepAlive(mutex);
|
||||
|
||||
30
tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs
Normal file
30
tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
36
tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs
Normal file
36
tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
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); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user