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>
656 lines
27 KiB
Markdown
656 lines
27 KiB
Markdown
# 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.
|