Compare commits
15 Commits
feat/relea
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b1f148122 | ||
|
|
2b3fe02d8c | ||
|
|
d3b85f2234 | ||
|
|
fc9029de97 | ||
|
|
1c764dae3f | ||
|
|
cfec3297a4 | ||
|
|
6e1d64b489 | ||
|
|
f599f8d0af | ||
|
|
9b928c6217 | ||
| c9e38aef88 | |||
| 66843d242b | |||
| 6afe5959ca | |||
|
|
9a407bde83 | ||
|
|
8c051d8f62 | ||
|
|
8577c55685 |
@@ -67,7 +67,7 @@ jobs:
|
|||||||
-c Release -r win-x64 --self-contained true \
|
-c Release -r win-x64 --self-contained true \
|
||||||
/p:Version=$VERSION -o out/worker
|
/p:Version=$VERSION -o out/worker
|
||||||
|
|
||||||
- name: Publish ClaudeDo.Installer (win-x64, single-file)
|
- name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
|
||||||
env:
|
env:
|
||||||
WORK: ${{ steps.ws.outputs.dir }}
|
WORK: ${{ steps.ws.outputs.dir }}
|
||||||
VERSION: ${{ steps.ver.outputs.version }}
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
@@ -75,8 +75,11 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export PATH="$DOTNET_ROOT:$PATH"
|
export PATH="$DOTNET_ROOT:$PATH"
|
||||||
cd "$WORK/src"
|
cd "$WORK/src"
|
||||||
|
# Framework-dependent — WPF runtime pack isn't distributed on Linux SDK;
|
||||||
|
# the previous self-contained bundle crashed at startup (apphost AV).
|
||||||
|
# Target machines need .NET 8 Desktop Runtime (x64).
|
||||||
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
|
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
|
||||||
-c Release -r win-x64 --self-contained true \
|
-c Release -r win-x64 --self-contained false \
|
||||||
/p:Version=$VERSION /p:PublishSingleFile=true \
|
/p:Version=$VERSION /p:PublishSingleFile=true \
|
||||||
-o out/installer
|
-o out/installer
|
||||||
|
|
||||||
|
|||||||
6
global.json
Normal file
6
global.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "8.0.418",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,16 @@ CREATE TABLE IF NOT EXISTS task_runs (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
|
CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS subtasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
completed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
order_num INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subtasks_task_id ON subtasks(task_id);
|
||||||
|
|
||||||
-- Seed: minimal tag set (ignored if already present)
|
-- Seed: minimal tag set (ignored if already present)
|
||||||
INSERT OR IGNORE INTO tags (name) VALUES ('agent');
|
INSERT OR IGNORE INTO tags (name) VALUES ('agent');
|
||||||
INSERT OR IGNORE INTO tags (name) VALUES ('manual');
|
INSERT OR IGNORE INTO tags (name) VALUES ('manual');
|
||||||
|
|||||||
BIN
src/ClaudeDo.App/Assets/ClaudeTask.ico
Normal file
BIN
src/ClaudeDo.App/Assets/ClaudeTask.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 317 B |
@@ -4,6 +4,7 @@
|
|||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<ApplicationIcon>Assets\ClaudeTask.ico</ApplicationIcon>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,20 @@ sealed class Program
|
|||||||
var factory = services.GetRequiredService<SqliteConnectionFactory>();
|
var factory = services.GetRequiredService<SqliteConnectionFactory>();
|
||||||
SchemaInitializer.Apply(factory);
|
SchemaInitializer.Apply(factory);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
BuildAvaloniaApp()
|
BuildAvaloniaApp()
|
||||||
.StartWithClassicDesktopLifetime(args);
|
.StartWithClassicDesktopLifetime(args);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Dispose the container so WorkerClient.DisposeAsync runs —
|
||||||
|
// cancels the retry loop and closes the SignalR connection cleanly
|
||||||
|
// instead of abandoning it.
|
||||||
|
try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
|
||||||
|
catch { /* best effort on shutdown */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static AppBuilder BuildAvaloniaApp()
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
=> AppBuilder.Configure<App>()
|
=> AppBuilder.Configure<App>()
|
||||||
@@ -49,6 +60,7 @@ sealed class Program
|
|||||||
// Repositories
|
// Repositories
|
||||||
sc.AddSingleton<ListRepository>();
|
sc.AddSingleton<ListRepository>();
|
||||||
sc.AddSingleton<TaskRepository>();
|
sc.AddSingleton<TaskRepository>();
|
||||||
|
sc.AddSingleton<SubtaskRepository>();
|
||||||
sc.AddSingleton<TagRepository>();
|
sc.AddSingleton<TagRepository>();
|
||||||
sc.AddSingleton<WorktreeRepository>();
|
sc.AddSingleton<WorktreeRepository>();
|
||||||
|
|
||||||
@@ -66,7 +78,8 @@ sealed class Program
|
|||||||
sp.GetRequiredService<ListRepository>(),
|
sp.GetRequiredService<ListRepository>(),
|
||||||
sp.GetRequiredService<GitService>(),
|
sp.GetRequiredService<GitService>(),
|
||||||
sp.GetRequiredService<WorkerClient>(),
|
sp.GetRequiredService<WorkerClient>(),
|
||||||
sp.GetRequiredService<TagRepository>()));
|
sp.GetRequiredService<TagRepository>(),
|
||||||
|
sp.GetRequiredService<SubtaskRepository>()));
|
||||||
sc.AddSingleton<TaskListViewModel>(sp =>
|
sc.AddSingleton<TaskListViewModel>(sp =>
|
||||||
{
|
{
|
||||||
var taskRepo = sp.GetRequiredService<TaskRepository>();
|
var taskRepo = sp.GetRequiredService<TaskRepository>();
|
||||||
|
|||||||
@@ -104,20 +104,34 @@ public sealed class GitService
|
|||||||
using var proc = new Process { StartInfo = psi };
|
using var proc = new Process { StartInfo = psi };
|
||||||
proc.Start();
|
proc.Start();
|
||||||
|
|
||||||
|
// On cancellation: kill the git process tree. Killing closes the
|
||||||
|
// redirected pipes, which unblocks the ReadToEndAsync calls below
|
||||||
|
// and lets WaitForExitAsync return so the process is reaped.
|
||||||
|
// Without this, cancelling mid-git leaves zombie processes.
|
||||||
|
await using var ctr = ct.Register(() =>
|
||||||
|
{
|
||||||
|
try { proc.Kill(entireProcessTree: true); }
|
||||||
|
catch { /* already exited */ }
|
||||||
|
});
|
||||||
|
|
||||||
if (stdinData is not null)
|
if (stdinData is not null)
|
||||||
{
|
{
|
||||||
await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct);
|
await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct);
|
||||||
proc.StandardInput.Close();
|
proc.StandardInput.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct);
|
// Drain output without ct — pipes close when the process exits
|
||||||
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
|
// (whether naturally or via Kill above), so these always complete.
|
||||||
|
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
|
||||||
|
var stderrTask = proc.StandardError.ReadToEndAsync();
|
||||||
|
|
||||||
await proc.WaitForExitAsync(ct);
|
await proc.WaitForExitAsync(CancellationToken.None);
|
||||||
|
|
||||||
var stdout = await stdoutTask;
|
var stdout = await stdoutTask;
|
||||||
var stderr = await stderrTask;
|
var stderr = await stderrTask;
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
|
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/ClaudeDo.Data/Models/SubtaskEntity.cs
Normal file
11
src/ClaudeDo.Data/Models/SubtaskEntity.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
public sealed class SubtaskEntity
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string TaskId { get; init; }
|
||||||
|
public required string Title { get; set; }
|
||||||
|
public bool Completed { get; set; }
|
||||||
|
public int OrderNum { get; set; }
|
||||||
|
public required DateTime CreatedAt { get; init; }
|
||||||
|
}
|
||||||
81
src/ClaudeDo.Data/Repositories/SubtaskRepository.cs
Normal file
81
src/ClaudeDo.Data/Repositories/SubtaskRepository.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Repositories;
|
||||||
|
|
||||||
|
public sealed class SubtaskRepository
|
||||||
|
{
|
||||||
|
private readonly SqliteConnectionFactory _factory;
|
||||||
|
|
||||||
|
public SubtaskRepository(SqliteConnectionFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
public async Task<List<SubtaskEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var conn = _factory.Open();
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT id, task_id, title, completed, order_num, created_at FROM subtasks WHERE task_id = @task_id ORDER BY order_num";
|
||||||
|
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
var result = new List<SubtaskEntity>();
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
result.Add(ReadSubtask(reader));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var conn = _factory.Open();
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT INTO subtasks (id, task_id, title, completed, order_num, created_at)
|
||||||
|
VALUES (@id, @task_id, @title, @completed, @order_num, @created_at)
|
||||||
|
""";
|
||||||
|
BindSubtask(cmd, entity);
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var conn = _factory.Open();
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
UPDATE subtasks SET title = @title, completed = @completed, order_num = @order_num
|
||||||
|
WHERE id = @id
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("@id", entity.Id);
|
||||||
|
cmd.Parameters.AddWithValue("@title", entity.Title);
|
||||||
|
cmd.Parameters.AddWithValue("@completed", entity.Completed ? 1 : 0);
|
||||||
|
cmd.Parameters.AddWithValue("@order_num", entity.OrderNum);
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var conn = _factory.Open();
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM subtasks WHERE id = @id";
|
||||||
|
cmd.Parameters.AddWithValue("@id", id);
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BindSubtask(SqliteCommand cmd, SubtaskEntity e)
|
||||||
|
{
|
||||||
|
cmd.Parameters.AddWithValue("@id", e.Id);
|
||||||
|
cmd.Parameters.AddWithValue("@task_id", e.TaskId);
|
||||||
|
cmd.Parameters.AddWithValue("@title", e.Title);
|
||||||
|
cmd.Parameters.AddWithValue("@completed", e.Completed ? 1 : 0);
|
||||||
|
cmd.Parameters.AddWithValue("@order_num", e.OrderNum);
|
||||||
|
cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubtaskEntity ReadSubtask(SqliteDataReader r) => new()
|
||||||
|
{
|
||||||
|
Id = r.GetString(0),
|
||||||
|
TaskId = r.GetString(1),
|
||||||
|
Title = r.GetString(2),
|
||||||
|
Completed = r.GetInt64(3) != 0,
|
||||||
|
OrderNum = r.GetInt32(4),
|
||||||
|
CreatedAt = DateTime.Parse(r.GetString(5)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -174,12 +174,18 @@ public sealed class TaskRepository
|
|||||||
|
|
||||||
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
// Atomically claim the next queued agent task: the UPDATE flips its
|
||||||
|
// status to 'running' in the same statement that returns its row,
|
||||||
|
// eliminating the TOCTOU gap where two queue-loop iterations could
|
||||||
|
// both select the same queued task before either marked it running.
|
||||||
|
// The caller is responsible for populating started_at shortly after.
|
||||||
await using var conn = _factory.Open();
|
await using var conn = _factory.Open();
|
||||||
await using var cmd = conn.CreateCommand();
|
await using var cmd = conn.CreateCommand();
|
||||||
cmd.CommandText = """
|
cmd.CommandText = """
|
||||||
SELECT t.id, t.list_id, t.title, t.description, t.status, t.scheduled_for,
|
UPDATE tasks
|
||||||
t.result, t.log_path, t.created_at, t.started_at, t.finished_at, t.commit_type,
|
SET status = 'running'
|
||||||
t.model, t.system_prompt, t.agent_path
|
WHERE id = (
|
||||||
|
SELECT t.id
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
WHERE t.status = 'queued'
|
WHERE t.status = 'queued'
|
||||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now)
|
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now)
|
||||||
@@ -194,6 +200,10 @@ public sealed class TaskRepository
|
|||||||
)
|
)
|
||||||
ORDER BY t.created_at ASC
|
ORDER BY t.created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
)
|
||||||
|
RETURNING id, list_id, title, description, status, scheduled_for,
|
||||||
|
result, log_path, created_at, started_at, finished_at, commit_type,
|
||||||
|
model, system_prompt, agent_path
|
||||||
""";
|
""";
|
||||||
cmd.Parameters.AddWithValue("@now", now.ToString("o"));
|
cmd.Parameters.AddWithValue("@now", now.ToString("o"));
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ public partial class App : Application
|
|||||||
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
||||||
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
|
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
|
||||||
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
||||||
|
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
|
||||||
sc.AddSingleton<WriteInstallManifestStep>();
|
sc.AddSingleton<WriteInstallManifestStep>();
|
||||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
|
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,16 @@
|
|||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<!-- Allow Linux Gitea runners to publish this WPF project for win-x64; no-op on Windows. -->
|
||||||
|
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||||
|
<ApplicationIcon>ClaudeTaskSetup.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Embed icon so it is available via pack URI in WPF windows. -->
|
||||||
|
<Resource Include="ClaudeTaskSetup.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Debug: asInvoker so Rider/VS can debug without elevation -->
|
<!-- Debug: asInvoker so Rider/VS can debug without elevation -->
|
||||||
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
|
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
|
||||||
<ApplicationManifest>app.debug.manifest</ApplicationManifest>
|
<ApplicationManifest>app.debug.manifest</ApplicationManifest>
|
||||||
@@ -19,10 +27,15 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">
|
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">
|
||||||
|
<!-- Framework-dependent: the WPF runtime pack isn't distributed for cross-compile
|
||||||
|
on Linux CI, which made self-contained bundles crash on startup with AV in the
|
||||||
|
apphost. Target machines already have the .NET 8 Desktop Runtime. -->
|
||||||
|
<SelfContained>false</SelfContained>
|
||||||
|
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
|
||||||
<PublishTrimmed>false</PublishTrimmed>
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
|
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
|
||||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
<EnableCompressionInSingleFile>false</EnableCompressionInSingleFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
BIN
src/ClaudeDo.Installer/ClaudeTaskSetup.ico
Normal file
BIN
src/ClaudeDo.Installer/ClaudeTaskSetup.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 374 B |
@@ -1,6 +1,8 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Installer.Steps;
|
using ClaudeDo.Installer.Steps;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
@@ -36,6 +38,17 @@ public sealed class UninstallRunner
|
|||||||
progress.Report("Unregistering service...");
|
progress.Report("Unregistering service...");
|
||||||
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
|
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
|
||||||
|
|
||||||
|
// 3b) Remove Apps & Features registry entry (best-effort).
|
||||||
|
progress.Report("Removing Add/Remove Programs entry...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Registry.LocalMachine.DeleteSubKeyTree(WriteUninstallRegistryStep.UninstallKeyPath, throwOnMissingSubKey: false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
progress.Report($"Warning: could not delete uninstall registry key: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
// 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
|
// 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
|
||||||
progress.Report("Removing shortcuts...");
|
progress.Report("Removing shortcuts...");
|
||||||
TryDeleteFile(Path.Combine(
|
TryDeleteFile(Path.Combine(
|
||||||
@@ -63,6 +76,21 @@ public sealed class UninstallRunner
|
|||||||
failures.Add($"app data ({appData}): {err}");
|
failures.Add($"app data ({appData}): {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7) If we were launched from inside the install dir (Apps & Features case),
|
||||||
|
// our own exe is still locked — schedule a cmd.exe trampoline to finish
|
||||||
|
// the deletion after this process exits. Best-effort: if this fails the
|
||||||
|
// user is left with an empty <uninstaller> folder which is harmless.
|
||||||
|
var runningExe = Environment.ProcessPath;
|
||||||
|
if (runningExe is not null
|
||||||
|
&& IsInsideDirectory(runningExe, _context.InstallDirectory)
|
||||||
|
&& Directory.Exists(_context.InstallDirectory))
|
||||||
|
{
|
||||||
|
progress.Report("Scheduling final cleanup after exit...");
|
||||||
|
TryScheduleTrampolineDelete(_context.InstallDirectory);
|
||||||
|
// The trampoline will finish the job — clear the residual failure entry for the install dir.
|
||||||
|
failures.RemoveAll(f => f.StartsWith("install dir"));
|
||||||
|
}
|
||||||
|
|
||||||
if (failures.Count > 0)
|
if (failures.Count > 0)
|
||||||
{
|
{
|
||||||
return StepResult.Fail(
|
return StepResult.Fail(
|
||||||
@@ -74,6 +102,37 @@ public sealed class UninstallRunner
|
|||||||
return StepResult.Ok();
|
return StepResult.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsInsideDirectory(string filePath, string directory)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var full = Path.GetFullPath(filePath);
|
||||||
|
var dir = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||||
|
return full.StartsWith(dir, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryScheduleTrampolineDelete(string installDir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pid = Environment.ProcessId;
|
||||||
|
// Wait for this process to exit, then recursively remove the install dir.
|
||||||
|
// /B timeout avoids a visible window; ping as a portable sleep; rmdir /S /Q is silent.
|
||||||
|
var cmd = $"/C start \"\" /MIN cmd /C \"ping 127.0.0.1 -n 3 >nul & rmdir /S /Q \"\"{installDir}\"\"\"";
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "cmd.exe",
|
||||||
|
Arguments = cmd,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Guards against catastrophic recursive-delete paths. The install dir must be
|
/// Guards against catastrophic recursive-delete paths. The install dir must be
|
||||||
/// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/").
|
/// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/").
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
|||||||
Steps.Add(new StepViewModel("Initialize Database"));
|
Steps.Add(new StepViewModel("Initialize Database"));
|
||||||
Steps.Add(new StepViewModel("Register Windows Service"));
|
Steps.Add(new StepViewModel("Register Windows Service"));
|
||||||
Steps.Add(new StepViewModel("Create Shortcuts"));
|
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("Write Install Manifest"));
|
||||||
}
|
}
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -82,7 +83,21 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
|||||||
|
|
||||||
step.Status = p.Status;
|
step.Status = p.Status;
|
||||||
if (p.Message is not null)
|
if (p.Message is not null)
|
||||||
|
{
|
||||||
|
// Messages starting with "\r" overwrite the previous line (live progress).
|
||||||
|
if (p.Message.StartsWith('\r'))
|
||||||
|
{
|
||||||
|
var line = p.Message[1..];
|
||||||
|
if (step.Messages.Count > 0 && step.Messages[^1].StartsWith(" "))
|
||||||
|
step.Messages[^1] = line;
|
||||||
|
else
|
||||||
|
step.Messages.Add(line);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
step.Messages.Add(p.Message);
|
step.Messages.Add(p.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (p.Status is StepStatus.Running && !step.IsExpanded)
|
if (p.Status is StepStatus.Running && !step.IsExpanded)
|
||||||
step.IsExpanded = true;
|
step.IsExpanded = true;
|
||||||
|
|||||||
@@ -44,9 +44,18 @@ public sealed class DownloadAndExtractStep : IInstallStep
|
|||||||
var zipPath = Path.Combine(scratchDir, zipAsset.Name);
|
var zipPath = Path.Combine(scratchDir, zipAsset.Name);
|
||||||
var checksumPath = Path.Combine(scratchDir, "checksums.txt");
|
var checksumPath = Path.Combine(scratchDir, "checksums.txt");
|
||||||
|
|
||||||
progress.Report($"Downloading {zipAsset.Name} ({zipAsset.Size / (1024 * 1024)} MB)...");
|
var totalMb = zipAsset.Size / (1024 * 1024);
|
||||||
|
progress.Report($"Downloading {zipAsset.Name} ({totalMb} MB)...");
|
||||||
|
long lastReportedMb = -1;
|
||||||
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
|
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
|
||||||
new Progress<long>(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")),
|
new Progress<long>(b =>
|
||||||
|
{
|
||||||
|
var mb = b / (1024 * 1024);
|
||||||
|
if (mb == lastReportedMb) return;
|
||||||
|
lastReportedMb = mb;
|
||||||
|
// Leading "\r" tells the UI to overwrite the previous line instead of appending.
|
||||||
|
progress.Report($"\r {mb} / {totalMb} MB downloaded");
|
||||||
|
}),
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
progress.Report("Downloading checksums...");
|
progress.Report("Downloading checksums...");
|
||||||
|
|||||||
@@ -23,6 +23,24 @@ public sealed class RegisterServiceStep : IInstallStep
|
|||||||
progress.Report("Removing existing service registration (if any)...");
|
progress.Report("Removing existing service registration (if any)...");
|
||||||
await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
|
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
|
// Create service
|
||||||
var startType = ctx.AutoStart ? "auto" : "demand";
|
var startType = ctx.AutoStart ? "auto" : "demand";
|
||||||
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
|
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
|
||||||
@@ -35,6 +53,10 @@ public sealed class RegisterServiceStep : IInstallStep
|
|||||||
|
|
||||||
progress.Report("Creating service...");
|
progress.Report("Creating service...");
|
||||||
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
|
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)
|
if (exitCode != 0)
|
||||||
return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
|
return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ public sealed class StopServiceStep : IInstallStep
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
|
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)
|
if (stopExit != 0)
|
||||||
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");
|
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");
|
||||||
|
|
||||||
|
|||||||
81
src/ClaudeDo.Installer/Steps/WriteUninstallRegistryStep.cs
Normal file
81
src/ClaudeDo.Installer/Steps/WriteUninstallRegistryStep.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
using System.IO;
|
||||||
|
using ClaudeDo.Installer.Core;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Steps;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers ClaudeDo under <c>HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo</c>
|
||||||
|
/// so it shows up in Windows "Apps & Features" / "Programs and Features".
|
||||||
|
/// Also copies the running installer into the install directory so there is an exe
|
||||||
|
/// for UninstallString to reference after the temp-extracted single-file bundle is gone.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WriteUninstallRegistryStep : IInstallStep
|
||||||
|
{
|
||||||
|
internal const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo";
|
||||||
|
|
||||||
|
public string Name => "Register in Add/Remove Programs";
|
||||||
|
|
||||||
|
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var uninstallDir = Path.Combine(ctx.InstallDirectory, "uninstaller");
|
||||||
|
Directory.CreateDirectory(uninstallDir);
|
||||||
|
var targetExe = Path.Combine(uninstallDir, "ClaudeDo.Installer.exe");
|
||||||
|
|
||||||
|
// Copy the running installer so Apps & Features has a stable exe to launch —
|
||||||
|
// the single-file temp extract is gone once this process exits.
|
||||||
|
var sourceExe = Environment.ProcessPath
|
||||||
|
?? throw new InvalidOperationException("Cannot resolve running installer path.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
progress.Report("Copying uninstaller binary...");
|
||||||
|
File.Copy(sourceExe, targetExe, overwrite: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.Report("Writing Add/Remove Programs entry...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Registry.LocalMachine.CreateSubKey(UninstallKeyPath, writable: true);
|
||||||
|
if (key is null)
|
||||||
|
return StepResult.Fail("Could not open uninstall registry key (permission denied?).");
|
||||||
|
|
||||||
|
key.SetValue("DisplayName", "ClaudeDo", RegistryValueKind.String);
|
||||||
|
key.SetValue("DisplayVersion", ctx.InstallerVersion ?? "0.0.0", RegistryValueKind.String);
|
||||||
|
key.SetValue("Publisher", "Mika Kuns", RegistryValueKind.String);
|
||||||
|
key.SetValue("InstallLocation", ctx.InstallDirectory, RegistryValueKind.String);
|
||||||
|
key.SetValue("UninstallString", $"\"{targetExe}\"", RegistryValueKind.String);
|
||||||
|
key.SetValue("DisplayIcon", targetExe, RegistryValueKind.String);
|
||||||
|
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
|
||||||
|
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
|
||||||
|
|
||||||
|
// Best-effort install size (KB) — scan install dir.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sizeKb = (int)(DirectorySizeBytes(ctx.InstallDirectory) / 1024);
|
||||||
|
key.SetValue("EstimatedSize", sizeKb, RegistryValueKind.DWord);
|
||||||
|
}
|
||||||
|
catch { /* best-effort only */ }
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StepResult.Fail($"Failed to write uninstall registry: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
|
return StepResult.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long DirectorySizeBytes(string path)
|
||||||
|
{
|
||||||
|
long total = 0;
|
||||||
|
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
try { total += new FileInfo(file).Length; } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,6 +184,34 @@
|
|||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- ComboBox toggle button (dropdown arrow chrome) -->
|
||||||
|
<ControlTemplate x:Key="ComboBoxToggleButtonTemplate" TargetType="ToggleButton">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{StaticResource IslandBgBrush}"
|
||||||
|
BorderBrush="{StaticResource BorderSubtleBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="4">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="20"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Path Grid.Column="1"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Fill="{StaticResource TextSecondaryBrush}"
|
||||||
|
Data="M 0 0 L 4 4 L 8 0 Z"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsChecked" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
|
||||||
<!-- ComboBox -->
|
<!-- ComboBox -->
|
||||||
<Style TargetType="ComboBox">
|
<Style TargetType="ComboBox">
|
||||||
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
|
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
|
||||||
@@ -191,6 +219,71 @@
|
|||||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
|
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
|
||||||
<Setter Property="BorderThickness" Value="1"/>
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
<Setter Property="Padding" Value="8,6"/>
|
<Setter Property="Padding" Value="8,6"/>
|
||||||
|
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ComboBox">
|
||||||
|
<Grid>
|
||||||
|
<ToggleButton x:Name="ToggleButton"
|
||||||
|
Template="{StaticResource ComboBoxToggleButtonTemplate}"
|
||||||
|
Focusable="False"
|
||||||
|
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
|
||||||
|
ClickMode="Press"/>
|
||||||
|
<ContentPresenter x:Name="ContentSite"
|
||||||
|
IsHitTestVisible="False"
|
||||||
|
Content="{TemplateBinding SelectionBoxItem}"
|
||||||
|
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||||
|
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
|
||||||
|
Margin="{TemplateBinding Padding}"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Left"
|
||||||
|
TextElement.Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||||
|
<Popup x:Name="Popup"
|
||||||
|
Placement="Bottom"
|
||||||
|
IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||||
|
AllowsTransparency="True" Focusable="False"
|
||||||
|
PopupAnimation="Slide">
|
||||||
|
<Border x:Name="DropDownBorder"
|
||||||
|
Background="{StaticResource IslandBgBrush}"
|
||||||
|
BorderBrush="{StaticResource BorderSubtleBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="4"
|
||||||
|
MinWidth="{TemplateBinding ActualWidth}"
|
||||||
|
MaxHeight="{TemplateBinding MaxDropDownHeight}">
|
||||||
|
<ScrollViewer SnapsToDevicePixels="True">
|
||||||
|
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ComboBoxItem — dark dropdown rows -->
|
||||||
|
<Style TargetType="ComboBoxItem">
|
||||||
|
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
|
||||||
|
<Setter Property="Padding" Value="8,6"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ComboBoxItem">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsHighlighted" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionHoverBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionBrush}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- CheckBox -->
|
<!-- CheckBox -->
|
||||||
|
|||||||
@@ -3,9 +3,14 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
|
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
|
||||||
Title="ClaudeDo Settings"
|
Title="ClaudeDo Settings"
|
||||||
|
Icon="/ClaudeTaskSetup.ico"
|
||||||
Width="720" Height="520"
|
Width="720" Height="520"
|
||||||
MinWidth="620" MinHeight="460"
|
MinWidth="620" MinHeight="460"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Background="{StaticResource WindowBgBrush}"
|
||||||
|
Foreground="{StaticResource TextPrimaryBrush}"
|
||||||
|
FontFamily="Segoe UI"
|
||||||
|
FontSize="13"
|
||||||
d:DataContext="{d:DesignInstance views:SettingsViewModel}"
|
d:DataContext="{d:DesignInstance views:SettingsViewModel}"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -3,9 +3,14 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
|
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
|
||||||
Title="ClaudeDo Installer"
|
Title="ClaudeDo Installer"
|
||||||
|
Icon="/ClaudeTaskSetup.ico"
|
||||||
Width="720" Height="520"
|
Width="720" Height="520"
|
||||||
MinWidth="620" MinHeight="460"
|
MinWidth="620" MinHeight="460"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Background="{StaticResource WindowBgBrush}"
|
||||||
|
Foreground="{StaticResource TextPrimaryBrush}"
|
||||||
|
FontFamily="Segoe UI"
|
||||||
|
FontSize="13"
|
||||||
d:DataContext="{d:DesignInstance views:WizardViewModel}"
|
d:DataContext="{d:DesignInstance views:WizardViewModel}"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
23
src/ClaudeDo.Ui/ViewModels/SubtaskItemViewModel.cs
Normal file
23
src/ClaudeDo.Ui/ViewModels/SubtaskItemViewModel.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
|
public partial class SubtaskItemViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty] private string _title = string.Empty;
|
||||||
|
[ObservableProperty] private bool _completed;
|
||||||
|
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string? OriginalTitle { get; set; }
|
||||||
|
public bool OriginalCompleted { get; set; }
|
||||||
|
|
||||||
|
public static SubtaskItemViewModel From(SubtaskEntity e) => new()
|
||||||
|
{
|
||||||
|
Id = e.Id,
|
||||||
|
Title = e.Title,
|
||||||
|
Completed = e.Completed,
|
||||||
|
OriginalTitle = e.Title,
|
||||||
|
OriginalCompleted = e.Completed,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
private readonly WorkerClient _worker;
|
private readonly WorkerClient _worker;
|
||||||
private readonly TagRepository _tagRepo;
|
private readonly TagRepository _tagRepo;
|
||||||
|
private readonly SubtaskRepository _subtaskRepo;
|
||||||
|
|
||||||
[ObservableProperty] private string _title = "";
|
[ObservableProperty] private string _title = "";
|
||||||
[ObservableProperty] private string? _description;
|
[ObservableProperty] private string? _description;
|
||||||
@@ -28,9 +29,14 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string _statusText = "";
|
[ObservableProperty] private string _statusText = "";
|
||||||
[ObservableProperty] private string _statusChoice = "Manual";
|
[ObservableProperty] private string _statusChoice = "Manual";
|
||||||
[ObservableProperty] private string _commitType = "chore";
|
[ObservableProperty] private string _commitType = "chore";
|
||||||
|
[ObservableProperty] private string _modelChoice = "(list default)";
|
||||||
|
[ObservableProperty] private string? _systemPromptOverride;
|
||||||
|
[ObservableProperty] private AgentInfo? _selectedAgent;
|
||||||
|
public List<AgentInfo> AvailableAgents { get; } = [];
|
||||||
|
|
||||||
public static string[] StatusChoices { get; } = ["Manual", "Queued", "Running", "Done", "Failed"];
|
public static string[] StatusChoices { get; } = ["Manual", "Queued", "Running", "Done", "Failed"];
|
||||||
public static string[] CommitTypes { get; } = ["feat", "fix", "refactor", "docs", "test", "chore", "ci", "perf", "style", "build"];
|
public static string[] CommitTypes { get; } = ["feat", "fix", "refactor", "docs", "test", "chore", "ci", "perf", "style", "build"];
|
||||||
|
public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
|
||||||
|
|
||||||
// Worktree
|
// Worktree
|
||||||
[ObservableProperty] private bool _hasWorktree;
|
[ObservableProperty] private bool _hasWorktree;
|
||||||
@@ -44,15 +50,21 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
private StreamLineFormatter _formatter = new();
|
private StreamLineFormatter _formatter = new();
|
||||||
public ObservableCollection<TagEntity> Tags { get; } = new();
|
public ObservableCollection<TagEntity> Tags { get; } = new();
|
||||||
[ObservableProperty] private string _newTagInput = "";
|
[ObservableProperty] private string _newTagInput = "";
|
||||||
|
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
|
||||||
|
|
||||||
private string? _taskId;
|
private string? _taskId;
|
||||||
private string? _listId;
|
private string? _listId;
|
||||||
private bool _isLoading;
|
private bool _isLoading;
|
||||||
|
// Cancels an in-flight LoadAsync when a new TaskUpdated event arrives
|
||||||
|
// before the previous load finished — prevents torn state on _taskId,
|
||||||
|
// Subtasks, Tags, etc.
|
||||||
|
private CancellationTokenSource? _loadCts;
|
||||||
|
|
||||||
public event Action<string>? TaskChanged;
|
public event Action<string>? TaskChanged;
|
||||||
|
|
||||||
public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo,
|
public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo,
|
||||||
ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo)
|
ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo,
|
||||||
|
SubtaskRepository subtaskRepo)
|
||||||
{
|
{
|
||||||
_taskRepo = taskRepo;
|
_taskRepo = taskRepo;
|
||||||
_worktreeRepo = worktreeRepo;
|
_worktreeRepo = worktreeRepo;
|
||||||
@@ -60,6 +72,7 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
_git = git;
|
_git = git;
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_tagRepo = tagRepo;
|
_tagRepo = tagRepo;
|
||||||
|
_subtaskRepo = subtaskRepo;
|
||||||
|
|
||||||
worker.TaskMessageEvent += OnTaskMessage;
|
worker.TaskMessageEvent += OnTaskMessage;
|
||||||
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
|
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
|
||||||
@@ -70,12 +83,32 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
|
|
||||||
public async Task LoadAsync(string taskId)
|
public async Task LoadAsync(string taskId)
|
||||||
{
|
{
|
||||||
|
// Cancel any in-flight load so rapid TaskUpdated events don't race
|
||||||
|
// on _taskId / Subtasks / Tags. The newest caller wins.
|
||||||
|
var oldCts = _loadCts;
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
_loadCts = cts;
|
||||||
|
oldCts?.Cancel();
|
||||||
|
oldCts?.Dispose();
|
||||||
|
var ct = cts.Token;
|
||||||
|
|
||||||
_taskId = taskId;
|
_taskId = taskId;
|
||||||
LiveText = "";
|
LiveText = "";
|
||||||
_formatter = new StreamLineFormatter();
|
_formatter = new StreamLineFormatter();
|
||||||
|
|
||||||
var task = await _taskRepo.GetByIdAsync(taskId);
|
try
|
||||||
|
{
|
||||||
|
var task = await _taskRepo.GetByIdAsync(taskId, ct);
|
||||||
if (task is null) return;
|
if (task is null) return;
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (AvailableAgents.Count == 0)
|
||||||
|
{
|
||||||
|
var agents = await _worker.GetAgentsAsync();
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
AvailableAgents.AddRange(agents);
|
||||||
|
OnPropertyChanged(nameof(AvailableAgents));
|
||||||
|
}
|
||||||
|
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
try
|
try
|
||||||
@@ -90,16 +123,46 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
&& File.Exists(task.LogPath))
|
&& File.Exists(task.LogPath))
|
||||||
{
|
{
|
||||||
_formatter = new StreamLineFormatter();
|
_formatter = new StreamLineFormatter();
|
||||||
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath));
|
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath), ct);
|
||||||
}
|
}
|
||||||
StatusText = task.Status.ToString().ToLowerInvariant();
|
StatusText = task.Status.ToString().ToLowerInvariant();
|
||||||
StatusChoice = task.Status.ToString();
|
StatusChoice = task.Status.ToString();
|
||||||
CommitType = task.CommitType;
|
CommitType = task.CommitType;
|
||||||
|
ModelChoice = task.Model is not null
|
||||||
|
? ListEditorViewModel.ModelIdToDisplay(task.Model)
|
||||||
|
: "(list default)";
|
||||||
|
SystemPromptOverride = task.SystemPrompt;
|
||||||
|
if (task.AgentPath is not null)
|
||||||
|
{
|
||||||
|
var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath);
|
||||||
|
if (match is null)
|
||||||
|
{
|
||||||
|
match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath);
|
||||||
|
AvailableAgents.Add(match);
|
||||||
|
OnPropertyChanged(nameof(AvailableAgents));
|
||||||
|
}
|
||||||
|
SelectedAgent = match;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SelectedAgent = null;
|
||||||
|
}
|
||||||
|
|
||||||
Tags.Clear();
|
Tags.Clear();
|
||||||
var tags = await _taskRepo.GetTagsAsync(taskId);
|
var tags = await _taskRepo.GetTagsAsync(taskId, ct);
|
||||||
foreach (var tag in tags)
|
foreach (var tag in tags)
|
||||||
Tags.Add(tag);
|
Tags.Add(tag);
|
||||||
|
|
||||||
|
// Tear down old subtask subscriptions before replacing them.
|
||||||
|
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
|
||||||
|
Subtasks.Clear();
|
||||||
|
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId, ct);
|
||||||
|
foreach (var s in subtasks)
|
||||||
|
{
|
||||||
|
var vm = SubtaskItemViewModel.From(s);
|
||||||
|
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
||||||
|
Subtasks.Add(vm);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -108,6 +171,11 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
|
|
||||||
await LoadWorktreeAsync(taskId);
|
await LoadWorktreeAsync(taskId);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Superseded by a newer LoadAsync — nothing to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SaveAsync()
|
public async Task SaveAsync()
|
||||||
{
|
{
|
||||||
@@ -119,6 +187,11 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
entity.Title = Title;
|
entity.Title = Title;
|
||||||
entity.Description = Description;
|
entity.Description = Description;
|
||||||
entity.CommitType = CommitType;
|
entity.CommitType = CommitType;
|
||||||
|
entity.Model = ModelChoice != "(list default)"
|
||||||
|
? ListEditorViewModel.ModelDisplayToId(ModelChoice)
|
||||||
|
: null;
|
||||||
|
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
|
||||||
|
entity.AgentPath = SelectedAgent?.Path;
|
||||||
|
|
||||||
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
|
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
|
||||||
entity.Status = status;
|
entity.Status = status;
|
||||||
@@ -155,8 +228,76 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
TaskChanged?.Invoke(_taskId);
|
TaskChanged?.Invoke(_taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AddSubtask()
|
||||||
|
{
|
||||||
|
if (_taskId is null) return;
|
||||||
|
var entity = new SubtaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
TaskId = _taskId,
|
||||||
|
Title = "",
|
||||||
|
Completed = false,
|
||||||
|
OrderNum = Subtasks.Count,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
await _subtaskRepo.AddAsync(entity);
|
||||||
|
var vm = SubtaskItemViewModel.From(entity);
|
||||||
|
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
||||||
|
Subtasks.Add(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RemoveSubtask(SubtaskItemViewModel item)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(item.Id))
|
||||||
|
await _subtaskRepo.DeleteAsync(item.Id);
|
||||||
|
item.PropertyChanged -= OnSubtaskPropertyChanged;
|
||||||
|
Subtasks.Remove(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnSubtaskPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return;
|
||||||
|
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _subtaskRepo.UpdateAsync(new SubtaskEntity
|
||||||
|
{
|
||||||
|
Id = vm.Id,
|
||||||
|
TaskId = _taskId ?? "",
|
||||||
|
Title = vm.Title,
|
||||||
|
Completed = vm.Completed,
|
||||||
|
OrderNum = Subtasks.IndexOf(vm),
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// async void must never throw — surface via Debug.
|
||||||
|
Debug.WriteLine($"[TaskDetailViewModel] Subtask update failed for {vm.Id}: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetAgentFromPath(string path)
|
||||||
|
{
|
||||||
|
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
existing = new AgentInfo(Path.GetFileNameWithoutExtension(path), "(external)", path);
|
||||||
|
AvailableAgents.Add(existing);
|
||||||
|
OnPropertyChanged(nameof(AvailableAgents));
|
||||||
|
}
|
||||||
|
SelectedAgent = existing;
|
||||||
|
}
|
||||||
|
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
|
// Cancel any load in flight so it doesn't resurrect state after Clear.
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
_loadCts?.Dispose();
|
||||||
|
_loadCts = null;
|
||||||
|
|
||||||
_taskId = null;
|
_taskId = null;
|
||||||
_listId = null;
|
_listId = null;
|
||||||
Title = "";
|
Title = "";
|
||||||
@@ -169,8 +310,13 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
_formatter = new StreamLineFormatter();
|
_formatter = new StreamLineFormatter();
|
||||||
Tags.Clear();
|
Tags.Clear();
|
||||||
NewTagInput = "";
|
NewTagInput = "";
|
||||||
|
foreach (var s in Subtasks) s.PropertyChanged -= OnSubtaskPropertyChanged;
|
||||||
|
Subtasks.Clear();
|
||||||
StatusChoice = "Manual";
|
StatusChoice = "Manual";
|
||||||
CommitType = "chore";
|
CommitType = "chore";
|
||||||
|
ModelChoice = "(list default)";
|
||||||
|
SystemPromptOverride = null;
|
||||||
|
SelectedAgent = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadWorktreeAsync(string taskId)
|
private async Task LoadWorktreeAsync(string taskId)
|
||||||
@@ -299,12 +445,28 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
private async void OnWorktreeUpdated(string taskId)
|
private async void OnWorktreeUpdated(string taskId)
|
||||||
{
|
{
|
||||||
if (taskId != _taskId) return;
|
if (taskId != _taskId) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
await LoadWorktreeAsync(taskId);
|
await LoadWorktreeAsync(taskId);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// async void must never throw.
|
||||||
|
Debug.WriteLine($"[TaskDetailViewModel] OnWorktreeUpdated failed for {taskId}: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnTaskUpdated(string taskId)
|
private async void OnTaskUpdated(string taskId)
|
||||||
{
|
{
|
||||||
if (taskId != _taskId) return;
|
if (taskId != _taskId) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
await LoadAsync(taskId);
|
await LoadAsync(taskId);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// async void must never throw.
|
||||||
|
Debug.WriteLine($"[TaskDetailViewModel] OnTaskUpdated failed for {taskId}: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
@@ -8,6 +11,8 @@ namespace ClaudeDo.Ui.ViewModels;
|
|||||||
|
|
||||||
public partial class TaskEditorViewModel : ViewModelBase
|
public partial class TaskEditorViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
|
private readonly SubtaskRepository _subtaskRepo;
|
||||||
|
|
||||||
[ObservableProperty] private string _title = "";
|
[ObservableProperty] private string _title = "";
|
||||||
[ObservableProperty] private string? _description;
|
[ObservableProperty] private string? _description;
|
||||||
[ObservableProperty] private string _commitType = "chore";
|
[ObservableProperty] private string _commitType = "chore";
|
||||||
@@ -18,6 +23,7 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string? _systemPromptOverride;
|
[ObservableProperty] private string? _systemPromptOverride;
|
||||||
[ObservableProperty] private AgentInfo? _selectedAgent;
|
[ObservableProperty] private AgentInfo? _selectedAgent;
|
||||||
public List<AgentInfo> AvailableAgents { get; set; } = [];
|
public List<AgentInfo> AvailableAgents { get; set; } = [];
|
||||||
|
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
|
||||||
|
|
||||||
private string? _editId;
|
private string? _editId;
|
||||||
private string _listId = "";
|
private string _listId = "";
|
||||||
@@ -34,11 +40,28 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
public static string[] StatusChoices { get; } =
|
public static string[] StatusChoices { get; } =
|
||||||
["manual", "queued"];
|
["manual", "queued"];
|
||||||
|
|
||||||
|
public TaskEditorViewModel(SubtaskRepository subtaskRepo)
|
||||||
|
{
|
||||||
|
_subtaskRepo = subtaskRepo;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task LoadAgentsAsync(WorkerClient worker)
|
public async Task LoadAgentsAsync(WorkerClient worker)
|
||||||
{
|
{
|
||||||
AvailableAgents = await worker.GetAgentsAsync();
|
AvailableAgents = await worker.GetAgentsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetAgentFromPath(string path)
|
||||||
|
{
|
||||||
|
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
existing = new AgentInfo(Path.GetFileNameWithoutExtension(path), "(external)", path);
|
||||||
|
AvailableAgents.Add(existing);
|
||||||
|
OnPropertyChanged(nameof(AvailableAgents));
|
||||||
|
}
|
||||||
|
SelectedAgent = existing;
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyList<string> SelectedTagNames =>
|
public IReadOnlyList<string> SelectedTagNames =>
|
||||||
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
@@ -51,8 +74,54 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
_createdAt = DateTime.UtcNow;
|
_createdAt = DateTime.UtcNow;
|
||||||
CommitType = defaultCommitType;
|
CommitType = defaultCommitType;
|
||||||
WindowTitle = "New Task";
|
WindowTitle = "New Task";
|
||||||
|
Subtasks.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task InitForEditAsync(TaskEntity entity, IReadOnlyList<TagEntity> taskTags, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_editId = entity.Id;
|
||||||
|
_listId = entity.ListId;
|
||||||
|
_createdAt = entity.CreatedAt;
|
||||||
|
Title = entity.Title;
|
||||||
|
Description = entity.Description;
|
||||||
|
CommitType = entity.CommitType;
|
||||||
|
StatusChoice = entity.Status switch
|
||||||
|
{
|
||||||
|
TaskStatus.Manual => "manual",
|
||||||
|
TaskStatus.Queued => "queued",
|
||||||
|
_ => entity.Status.ToString().ToLowerInvariant(),
|
||||||
|
};
|
||||||
|
TagsInput = string.Join(", ", taskTags.Select(t => t.Name));
|
||||||
|
ModelChoice = entity.Model is not null
|
||||||
|
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
|
||||||
|
: "(list default)";
|
||||||
|
SystemPromptOverride = entity.SystemPrompt;
|
||||||
|
|
||||||
|
if (entity.AgentPath is not null)
|
||||||
|
{
|
||||||
|
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
|
||||||
|
if (match is null)
|
||||||
|
{
|
||||||
|
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
|
||||||
|
AvailableAgents.Add(match);
|
||||||
|
OnPropertyChanged(nameof(AvailableAgents));
|
||||||
|
}
|
||||||
|
SelectedAgent = match;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SelectedAgent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowTitle = $"Edit Task: {entity.Title}";
|
||||||
|
|
||||||
|
Subtasks.Clear();
|
||||||
|
var list = await _subtaskRepo.GetByTaskIdAsync(entity.Id, ct);
|
||||||
|
foreach (var s in list)
|
||||||
|
Subtasks.Add(SubtaskItemViewModel.From(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep old sync overload for callers that haven't loaded agents yet
|
||||||
public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags)
|
public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags)
|
||||||
{
|
{
|
||||||
_editId = entity.Id;
|
_editId = entity.Id;
|
||||||
@@ -72,14 +141,34 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
|
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
|
||||||
: "(list default)";
|
: "(list default)";
|
||||||
SystemPromptOverride = entity.SystemPrompt;
|
SystemPromptOverride = entity.SystemPrompt;
|
||||||
SelectedAgent = entity.AgentPath is not null
|
|
||||||
? AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath)
|
if (entity.AgentPath is not null)
|
||||||
: null;
|
{
|
||||||
|
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
|
||||||
|
if (match is null)
|
||||||
|
{
|
||||||
|
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
|
||||||
|
AvailableAgents.Add(match);
|
||||||
|
OnPropertyChanged(nameof(AvailableAgents));
|
||||||
|
}
|
||||||
|
SelectedAgent = match;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SelectedAgent = null;
|
||||||
|
}
|
||||||
|
|
||||||
WindowTitle = $"Edit Task: {entity.Title}";
|
WindowTitle = $"Edit Task: {entity.Title}";
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Save()
|
private void AddSubtask() => Subtasks.Add(new SubtaskItemViewModel());
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RemoveSubtask(SubtaskItemViewModel item) => Subtasks.Remove(item);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task Save()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(Title)) return;
|
if (string.IsNullOrWhiteSpace(Title)) return;
|
||||||
var status = StatusChoice switch
|
var status = StatusChoice switch
|
||||||
@@ -87,9 +176,10 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
"queued" => TaskStatus.Queued,
|
"queued" => TaskStatus.Queued,
|
||||||
_ => TaskStatus.Manual,
|
_ => TaskStatus.Manual,
|
||||||
};
|
};
|
||||||
|
var taskId = _editId ?? Guid.NewGuid().ToString();
|
||||||
var entity = new TaskEntity
|
var entity = new TaskEntity
|
||||||
{
|
{
|
||||||
Id = _editId ?? Guid.NewGuid().ToString(),
|
Id = taskId,
|
||||||
ListId = _listId,
|
ListId = _listId,
|
||||||
Title = Title.Trim(),
|
Title = Title.Trim(),
|
||||||
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
|
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
|
||||||
@@ -102,6 +192,42 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
: null;
|
: null;
|
||||||
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
|
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
|
||||||
entity.AgentPath = SelectedAgent?.Path;
|
entity.AgentPath = SelectedAgent?.Path;
|
||||||
|
|
||||||
|
// Persist subtask changes
|
||||||
|
if (_editId is not null)
|
||||||
|
{
|
||||||
|
var existing = await _subtaskRepo.GetByTaskIdAsync(taskId);
|
||||||
|
var existingIds = existing.Select(s => s.Id).ToHashSet();
|
||||||
|
var currentIds = Subtasks.Where(s => s.Id != "").Select(s => s.Id).ToHashSet();
|
||||||
|
|
||||||
|
// Deleted
|
||||||
|
foreach (var id in existingIds.Except(currentIds))
|
||||||
|
await _subtaskRepo.DeleteAsync(id);
|
||||||
|
|
||||||
|
// Updated
|
||||||
|
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)))
|
||||||
|
{
|
||||||
|
if (vm.Id == "") continue;
|
||||||
|
if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted)
|
||||||
|
await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// update order_num if position changed
|
||||||
|
var orig = existing.FirstOrDefault(e => e.Id == vm.Id);
|
||||||
|
if (orig is not null && orig.OrderNum != idx)
|
||||||
|
await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = orig.CreatedAt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Added (id == "" means new)
|
||||||
|
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == ""))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(vm.Title)) continue;
|
||||||
|
var newId = Guid.NewGuid().ToString();
|
||||||
|
await _subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
|
||||||
|
}
|
||||||
|
|
||||||
_tcs.TrySetResult(entity);
|
_tcs.TrySetResult(entity);
|
||||||
RequestClose?.Invoke();
|
RequestClose?.Invoke();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
var taskTags = await _taskRepo.GetTagsAsync(entity.Id);
|
var taskTags = await _taskRepo.GetTagsAsync(entity.Id);
|
||||||
var editor = _editorFactory();
|
var editor = _editorFactory();
|
||||||
await editor.LoadAgentsAsync(_worker);
|
await editor.LoadAgentsAsync(_worker);
|
||||||
editor.InitForEdit(entity, taskTags);
|
await editor.InitForEditAsync(entity, taskTags);
|
||||||
|
|
||||||
var window = new TaskEditorView { DataContext = editor };
|
var window = new TaskEditorView { DataContext = editor };
|
||||||
editor.RequestClose += () => window.Close();
|
editor.RequestClose += () => window.Close();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
x:Class="ClaudeDo.Ui.Views.MainWindow"
|
x:Class="ClaudeDo.Ui.Views.MainWindow"
|
||||||
x:DataType="vm:MainWindowViewModel"
|
x:DataType="vm:MainWindowViewModel"
|
||||||
Title="ClaudeDo"
|
Title="ClaudeDo"
|
||||||
|
Icon="avares://ClaudeDo.App/Assets/ClaudeTask.ico"
|
||||||
MinWidth="800" MinHeight="500"
|
MinWidth="800" MinHeight="500"
|
||||||
KeyDown="OnGlobalKeyDown">
|
KeyDown="OnGlobalKeyDown">
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
|
|
||||||
<!-- Lists island -->
|
<!-- Lists island -->
|
||||||
<Border Grid.Column="0" CornerRadius="12" Background="{StaticResource IslandBgBrush}"
|
<Border Grid.Column="0" CornerRadius="12" Background="{StaticResource IslandBgBrush}"
|
||||||
MinWidth="180" MaxWidth="320" Margin="0,0,4,8" ClipToBounds="True">
|
MinWidth="180" Margin="0,0,4,8" ClipToBounds="True">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<TextBlock DockPanel.Dock="Top"
|
<TextBlock DockPanel.Dock="Top"
|
||||||
Text="Lists" FontWeight="SemiBold" FontSize="13"
|
Text="Lists" FontWeight="SemiBold" FontSize="13"
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
|
|
||||||
<!-- Detail island -->
|
<!-- Detail island -->
|
||||||
<Border Grid.Column="2" CornerRadius="12" Background="{StaticResource IslandBgBrush}"
|
<Border Grid.Column="2" CornerRadius="12" Background="{StaticResource IslandBgBrush}"
|
||||||
MinWidth="280" MaxWidth="500" Margin="4,0,0,8" ClipToBounds="True">
|
MinWidth="280" Margin="4,0,0,8" ClipToBounds="True">
|
||||||
<v:TaskDetailView DataContext="{Binding TaskDetail}" />
|
<v:TaskDetailView DataContext="{Binding TaskDetail}" />
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -86,6 +86,71 @@
|
|||||||
PlaceholderText="Add a description..."
|
PlaceholderText="Add a description..."
|
||||||
LostFocus="OnFieldLostFocus"/>
|
LostFocus="OnFieldLostFocus"/>
|
||||||
|
|
||||||
|
<!-- Sub-Tasks -->
|
||||||
|
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,8,0,4"/>
|
||||||
|
<TextBlock Text="Sub-Tasks" FontWeight="Bold" FontSize="13"
|
||||||
|
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Subtasks}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,2,0,2">
|
||||||
|
<CheckBox IsChecked="{Binding Completed}" VerticalAlignment="Center"/>
|
||||||
|
<TextBox Text="{Binding Title}" PlaceholderText="Subtask title..." Width="220"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
LostFocus="OnSubtaskTitleLostFocus"/>
|
||||||
|
<Button Content="✕" Padding="6,2"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Foreground="{StaticResource TextMutedBrush}"
|
||||||
|
Command="{Binding $parent[UserControl].((vm:TaskDetailViewModel)DataContext).RemoveSubtaskCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<Button Content="+ Add Sub-Task" Command="{Binding AddSubtaskCommand}"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Foreground="{StaticResource AccentLightBrush}" HorizontalAlignment="Left"/>
|
||||||
|
|
||||||
|
<!-- Agent Config (overrides) -->
|
||||||
|
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,8,0,4"/>
|
||||||
|
<TextBlock Text="Agent Config (overrides)" FontWeight="Bold" FontSize="13"
|
||||||
|
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,12,*" Margin="0,4,0,0">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Text="Model" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||||
|
<ComboBox ItemsSource="{Binding ModelChoices}"
|
||||||
|
SelectedItem="{Binding ModelChoice}"
|
||||||
|
MinWidth="100"
|
||||||
|
LostFocus="OnFieldLostFocus"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
|
<TextBlock Text="Agent File" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
|
<ComboBox ItemsSource="{Binding AvailableAgents}"
|
||||||
|
SelectedItem="{Binding SelectedAgent}"
|
||||||
|
MinWidth="100"
|
||||||
|
LostFocus="OnFieldLostFocus">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="m:AgentInfo">
|
||||||
|
<TextBlock Text="{Binding Name}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<Button Content="…" Click="OnBrowseAgent" ToolTip.Tip="Browse for agent .md file"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="System Prompt" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,2"/>
|
||||||
|
<TextBox Text="{Binding SystemPromptOverride}"
|
||||||
|
PlaceholderText="(inherits from list)"
|
||||||
|
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="50"
|
||||||
|
LostFocus="OnFieldLostFocus"/>
|
||||||
|
|
||||||
<!-- === READ-ONLY ZONE === -->
|
<!-- === READ-ONLY ZONE === -->
|
||||||
|
|
||||||
<TextBlock Text="Result" FontWeight="SemiBold" FontSize="12"
|
<TextBlock Text="Result" FontWeight="SemiBold" FontSize="12"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.ComponentModel;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views;
|
namespace ClaudeDo.Ui.Views;
|
||||||
@@ -19,6 +20,31 @@ public partial class TaskDetailView : UserControl
|
|||||||
await vm.SaveAsync();
|
await vm.SaveAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnSubtaskTitleLostFocus(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
// Title change is handled by SubtaskItemViewModel.PropertyChanged → OnSubtaskPropertyChanged in the VM
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnBrowseAgent(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
if (topLevel is null) return;
|
||||||
|
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Select Agent File",
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter = new[] { new FilePickerFileType("Agent Files") { Patterns = ["*.md"] } },
|
||||||
|
});
|
||||||
|
if (files.Count == 0) return;
|
||||||
|
var path = files[0].TryGetLocalPath();
|
||||||
|
if (path is null) return;
|
||||||
|
if (DataContext is TaskDetailViewModel vm)
|
||||||
|
{
|
||||||
|
vm.SetAgentFromPath(path);
|
||||||
|
await vm.SaveAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void OnTagInputKeyDown(object? sender, KeyEventArgs e)
|
private void OnTagInputKeyDown(object? sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Key == Key.Enter && DataContext is TaskDetailViewModel vm)
|
if (e.Key == Key.Enter && DataContext is TaskDetailViewModel vm)
|
||||||
|
|||||||
@@ -35,6 +35,30 @@
|
|||||||
<TextBlock Text="Tags (comma-separated)" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
|
<TextBlock Text="Tags (comma-separated)" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||||
<TextBox Text="{Binding TagsInput}" PlaceholderText="agent, manual, code, ..."/>
|
<TextBox Text="{Binding TagsInput}" PlaceholderText="agent, manual, code, ..."/>
|
||||||
|
|
||||||
|
<!-- Sub-Tasks -->
|
||||||
|
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
|
||||||
|
<TextBlock Text="Sub-Tasks" FontWeight="Bold" FontSize="13"
|
||||||
|
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Subtasks}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,2,0,2">
|
||||||
|
<CheckBox IsChecked="{Binding Completed}" VerticalAlignment="Center"/>
|
||||||
|
<TextBox Text="{Binding Title}" PlaceholderText="Subtask title..." Width="320"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Content="✕" Padding="6,2"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Foreground="{StaticResource TextMutedBrush}"
|
||||||
|
Command="{Binding $parent[Window].((vm:TaskEditorViewModel)DataContext).RemoveSubtaskCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<Button Content="+ Add Sub-Task" Command="{Binding AddSubtaskCommand}"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Foreground="{StaticResource AccentLightBrush}" HorizontalAlignment="Left"/>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
|
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
|
||||||
|
|
||||||
@@ -55,6 +79,7 @@
|
|||||||
|
|
||||||
<TextBlock Text="Agent File" FontWeight="SemiBold"
|
<TextBlock Text="Agent File" FontWeight="SemiBold"
|
||||||
Foreground="{StaticResource TextSecondaryBrush}"/>
|
Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<ComboBox ItemsSource="{Binding AvailableAgents}"
|
<ComboBox ItemsSource="{Binding AvailableAgents}"
|
||||||
SelectedItem="{Binding SelectedAgent}"
|
SelectedItem="{Binding SelectedAgent}"
|
||||||
MinWidth="150">
|
MinWidth="150">
|
||||||
@@ -64,6 +89,8 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
<Button Content="…" Click="OnBrowseAgent" ToolTip.Tip="Browse for agent .md file"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0">
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||||
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"
|
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views;
|
namespace ClaudeDo.Ui.Views;
|
||||||
|
|
||||||
@@ -8,4 +11,19 @@ public partial class TaskEditorView : Window
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnBrowseAgent(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Select Agent File",
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter = new[] { new FilePickerFileType("Agent Files") { Patterns = ["*.md"] } },
|
||||||
|
});
|
||||||
|
if (files.Count == 0) return;
|
||||||
|
var path = files[0].TryGetLocalPath();
|
||||||
|
if (path is null) return;
|
||||||
|
if (DataContext is TaskEditorViewModel vm)
|
||||||
|
vm.SetAgentFromPath(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ var cfg = WorkerConfig.Load();
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
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");
|
||||||
|
|
||||||
// Initialize DB schema before the host starts accepting connections.
|
// Initialize DB schema before the host starts accepting connections.
|
||||||
var dbFactory = new SqliteConnectionFactory(cfg.DbPath);
|
var dbFactory = new SqliteConnectionFactory(cfg.DbPath);
|
||||||
SchemaInitializer.Apply(dbFactory);
|
SchemaInitializer.Apply(dbFactory);
|
||||||
@@ -19,6 +23,7 @@ builder.Services.AddSingleton(dbFactory);
|
|||||||
builder.Services.AddSingleton<TagRepository>();
|
builder.Services.AddSingleton<TagRepository>();
|
||||||
builder.Services.AddSingleton<ListRepository>();
|
builder.Services.AddSingleton<ListRepository>();
|
||||||
builder.Services.AddSingleton<TaskRepository>();
|
builder.Services.AddSingleton<TaskRepository>();
|
||||||
|
builder.Services.AddSingleton<SubtaskRepository>();
|
||||||
builder.Services.AddSingleton<WorktreeRepository>();
|
builder.Services.AddSingleton<WorktreeRepository>();
|
||||||
builder.Services.AddSingleton<TaskRunRepository>();
|
builder.Services.AddSingleton<TaskRunRepository>();
|
||||||
builder.Services.AddHostedService<StaleTaskRecovery>();
|
builder.Services.AddHostedService<StaleTaskRecovery>();
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ public sealed class ClaudeProcess : IClaudeProcess
|
|||||||
var analyzer = new StreamAnalyzer();
|
var analyzer = new StreamAnalyzer();
|
||||||
var lastStderr = new StringBuilder();
|
var lastStderr = new StringBuilder();
|
||||||
|
|
||||||
|
// On cancellation: kill the tree. Killing closes the redirected pipes,
|
||||||
|
// which unblocks the ReadLineAsync loops below (which run without ct
|
||||||
|
// so they reliably drain instead of hanging on cancellation).
|
||||||
await using var ctr = ct.Register(() =>
|
await using var ctr = ct.Register(() =>
|
||||||
{
|
{
|
||||||
try { process.Kill(entireProcessTree: true); }
|
try { process.Kill(entireProcessTree: true); }
|
||||||
@@ -53,26 +56,30 @@ public sealed class ClaudeProcess : IClaudeProcess
|
|||||||
|
|
||||||
var stdoutTask = Task.Run(async () =>
|
var stdoutTask = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
while (await process.StandardOutput.ReadLineAsync(ct) is { } line)
|
while (await process.StandardOutput.ReadLineAsync() is { } line)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(line)) continue;
|
if (string.IsNullOrEmpty(line)) continue;
|
||||||
await onStdoutLine(line);
|
await onStdoutLine(line);
|
||||||
analyzer.ProcessLine(line);
|
analyzer.ProcessLine(line);
|
||||||
}
|
}
|
||||||
}, ct);
|
});
|
||||||
|
|
||||||
var stderrTask = Task.Run(async () =>
|
var stderrTask = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
while (await process.StandardError.ReadLineAsync(ct) is { } line)
|
while (await process.StandardError.ReadLineAsync() is { } line)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(line)) continue;
|
if (string.IsNullOrEmpty(line)) continue;
|
||||||
lastStderr.AppendLine(line);
|
lastStderr.AppendLine(line);
|
||||||
await onStdoutLine($"[stderr] {line}");
|
await onStdoutLine($"[stderr] {line}");
|
||||||
}
|
}
|
||||||
}, ct);
|
});
|
||||||
|
|
||||||
await Task.WhenAll(stdoutTask, stderrTask);
|
await Task.WhenAll(stdoutTask, stderrTask);
|
||||||
await process.WaitForExitAsync(ct);
|
await process.WaitForExitAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// If we were asked to cancel, surface that to the caller now that
|
||||||
|
// the process is fully reaped.
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var exitCode = process.ExitCode;
|
var exitCode = process.ExitCode;
|
||||||
var streamResult = analyzer.GetResult();
|
var streamResult = analyzer.GetResult();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public sealed class TaskRunner
|
|||||||
private readonly TaskRunRepository _runRepo;
|
private readonly TaskRunRepository _runRepo;
|
||||||
private readonly ListRepository _listRepo;
|
private readonly ListRepository _listRepo;
|
||||||
private readonly WorktreeRepository _wtRepo;
|
private readonly WorktreeRepository _wtRepo;
|
||||||
|
private readonly SubtaskRepository _subtaskRepo;
|
||||||
private readonly HubBroadcaster _broadcaster;
|
private readonly HubBroadcaster _broadcaster;
|
||||||
private readonly WorktreeManager _wtManager;
|
private readonly WorktreeManager _wtManager;
|
||||||
private readonly ClaudeArgsBuilder _argsBuilder;
|
private readonly ClaudeArgsBuilder _argsBuilder;
|
||||||
@@ -24,6 +25,7 @@ public sealed class TaskRunner
|
|||||||
TaskRunRepository runRepo,
|
TaskRunRepository runRepo,
|
||||||
ListRepository listRepo,
|
ListRepository listRepo,
|
||||||
WorktreeRepository wtRepo,
|
WorktreeRepository wtRepo,
|
||||||
|
SubtaskRepository subtaskRepo,
|
||||||
HubBroadcaster broadcaster,
|
HubBroadcaster broadcaster,
|
||||||
WorktreeManager wtManager,
|
WorktreeManager wtManager,
|
||||||
ClaudeArgsBuilder argsBuilder,
|
ClaudeArgsBuilder argsBuilder,
|
||||||
@@ -35,6 +37,7 @@ public sealed class TaskRunner
|
|||||||
_runRepo = runRepo;
|
_runRepo = runRepo;
|
||||||
_listRepo = listRepo;
|
_listRepo = listRepo;
|
||||||
_wtRepo = wtRepo;
|
_wtRepo = wtRepo;
|
||||||
|
_subtaskRepo = subtaskRepo;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
_wtManager = wtManager;
|
_wtManager = wtManager;
|
||||||
_argsBuilder = argsBuilder;
|
_argsBuilder = argsBuilder;
|
||||||
@@ -91,9 +94,16 @@ public sealed class TaskRunner
|
|||||||
await _broadcaster.TaskStarted(slot, task.Id, now);
|
await _broadcaster.TaskStarted(slot, task.Id, now);
|
||||||
|
|
||||||
// Build prompt.
|
// Build prompt.
|
||||||
var prompt = string.IsNullOrWhiteSpace(task.Description)
|
var subtasks = await _subtaskRepo.GetByTaskIdAsync(task.Id, ct);
|
||||||
? task.Title
|
var sb = new System.Text.StringBuilder(task.Title);
|
||||||
: $"{task.Title}\n\n{task.Description.Trim()}";
|
if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim());
|
||||||
|
if (subtasks.Count > 0)
|
||||||
|
{
|
||||||
|
sb.Append("\n\n## Sub-Tasks\n");
|
||||||
|
foreach (var s in subtasks)
|
||||||
|
sb.Append(s.Completed ? "- [x] " : "- [ ] ").Append(s.Title).Append('\n');
|
||||||
|
}
|
||||||
|
var prompt = sb.ToString();
|
||||||
|
|
||||||
// Run 1.
|
// Run 1.
|
||||||
var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct);
|
var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct);
|
||||||
@@ -222,6 +232,8 @@ public sealed class TaskRunner
|
|||||||
|
|
||||||
await using var logWriter = new LogWriter(logPath);
|
await using var logWriter = new LogWriter(logPath);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
var result = await _claude.RunAsync(
|
var result = await _claude.RunAsync(
|
||||||
arguments,
|
arguments,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -233,7 +245,9 @@ public sealed class TaskRunner
|
|||||||
},
|
},
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
// Update the run record with results.
|
// Update the run record with results. Use CancellationToken.None:
|
||||||
|
// this is a terminal write that must always complete, even if the
|
||||||
|
// caller's token is already cancelled.
|
||||||
run.SessionId = result.SessionId;
|
run.SessionId = result.SessionId;
|
||||||
run.ResultMarkdown = result.ResultMarkdown;
|
run.ResultMarkdown = result.ResultMarkdown;
|
||||||
run.StructuredOutputJson = result.StructuredOutputJson;
|
run.StructuredOutputJson = result.StructuredOutputJson;
|
||||||
@@ -243,13 +257,32 @@ public sealed class TaskRunner
|
|||||||
run.TokensIn = result.TokensIn;
|
run.TokensIn = result.TokensIn;
|
||||||
run.TokensOut = result.TokensOut;
|
run.TokensOut = result.TokensOut;
|
||||||
run.FinishedAt = DateTime.UtcNow;
|
run.FinishedAt = DateTime.UtcNow;
|
||||||
await _runRepo.UpdateAsync(run, ct);
|
await _runRepo.UpdateAsync(run, CancellationToken.None);
|
||||||
|
|
||||||
// Update denormalized fields on the task.
|
// Update denormalized fields on the task.
|
||||||
await _taskRepo.SetLogPathAsync(taskId, logPath, ct);
|
await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Ensure the run row is completed so ContinueAsync / inspection
|
||||||
|
// isn't left staring at a null session_id / finished_at.
|
||||||
|
run.ErrorMarkdown = "Cancelled.";
|
||||||
|
run.ExitCode = -1;
|
||||||
|
run.FinishedAt = DateTime.UtcNow;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _runRepo.UpdateAsync(run, CancellationToken.None);
|
||||||
|
await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception updateEx)
|
||||||
|
{
|
||||||
|
_logger.LogError(updateEx, "Failed to finalize cancelled run {RunId} for task {TaskId}", runId, taskId);
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct)
|
private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -260,8 +293,11 @@ public sealed class TaskRunner
|
|||||||
await _broadcaster.WorktreeUpdated(task.Id);
|
await _broadcaster.WorktreeUpdated(task.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terminal DB write uses CancellationToken.None so the task status
|
||||||
|
// is never left as 'running' because of a cancel that arrived
|
||||||
|
// after the Claude run already succeeded.
|
||||||
var finishedAt = DateTime.UtcNow;
|
var finishedAt = DateTime.UtcNow;
|
||||||
await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, ct);
|
await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
||||||
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
||||||
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
|
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
|
||||||
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
|
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
|
||||||
@@ -269,8 +305,10 @@ public sealed class TaskRunner
|
|||||||
|
|
||||||
private async Task HandleFailure(string taskId, string slot, RunResult result)
|
private async Task HandleFailure(string taskId, string slot, RunResult result)
|
||||||
{
|
{
|
||||||
|
// Intentionally does not accept a CancellationToken: this is the
|
||||||
|
// terminal write for a failed task and must always be persisted.
|
||||||
var finishedAt = DateTime.UtcNow;
|
var finishedAt = DateTime.UtcNow;
|
||||||
await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown);
|
await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
|
||||||
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
|
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
|
||||||
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
|
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
|
||||||
}
|
}
|
||||||
@@ -280,7 +318,8 @@ public sealed class TaskRunner
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
await _taskRepo.MarkFailedAsync(taskId, now, error);
|
// Terminal write — never cancel.
|
||||||
|
await _taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
|
||||||
await _broadcaster.TaskFinished(slot, taskId, "failed", now);
|
await _broadcaster.TaskFinished(slot, taskId, "failed", now);
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ public sealed class WorktreeManager
|
|||||||
throw new InvalidOperationException($"working_dir is not a git repository: {workingDir}");
|
throw new InvalidOperationException($"working_dir is not a git repository: {workingDir}");
|
||||||
|
|
||||||
var baseCommit = await _git.RevParseHeadAsync(workingDir, ct);
|
var baseCommit = await _git.RevParseHeadAsync(workingDir, ct);
|
||||||
var shortId = task.Id.Length >= 8 ? task.Id[..8] : task.Id;
|
// Use the full task id (dashes stripped) in the branch name so
|
||||||
var branchName = $"claudedo/{shortId}";
|
// two GUIDs sharing an 8-char prefix cannot collide on the same branch.
|
||||||
|
var idForBranch = task.Id.Replace("-", "");
|
||||||
|
var branchName = $"claudedo/{idForBranch}";
|
||||||
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
||||||
|
|
||||||
var worktreePath = _cfg.WorktreeRootStrategy.Equals("central", StringComparison.OrdinalIgnoreCase)
|
var worktreePath = _cfg.WorktreeRootStrategy.Equals("central", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public sealed class QueueService : BackgroundService
|
|||||||
_ = RunInSlotAsync(task, "override", cts.Token).ContinueWith(_ =>
|
_ = RunInSlotAsync(task, "override", cts.Token).ContinueWith(_ =>
|
||||||
{
|
{
|
||||||
lock (_lock) { _overrideSlot = null; }
|
lock (_lock) { _overrideSlot = null; }
|
||||||
|
cts.Dispose();
|
||||||
}, TaskScheduler.Default);
|
}, TaskScheduler.Default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,6 +95,7 @@ public sealed class QueueService : BackgroundService
|
|||||||
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(_ =>
|
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(_ =>
|
||||||
{
|
{
|
||||||
lock (_lock) { _overrideSlot = null; }
|
lock (_lock) { _overrideSlot = null; }
|
||||||
|
cts.Dispose();
|
||||||
}, TaskScheduler.Default);
|
}, TaskScheduler.Default);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +157,7 @@ public sealed class QueueService : BackgroundService
|
|||||||
_ = RunInSlotAsync(task, "queue", cts.Token).ContinueWith(_ =>
|
_ = RunInSlotAsync(task, "queue", cts.Token).ContinueWith(_ =>
|
||||||
{
|
{
|
||||||
lock (_lock) { _queueSlot = null; }
|
lock (_lock) { _queueSlot = null; }
|
||||||
|
cts.Dispose();
|
||||||
WakeQueue(); // Check for next task immediately.
|
WakeQueue(); // Check for next task immediately.
|
||||||
}, TaskScheduler.Default);
|
}, TaskScheduler.Default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<!-- Allow Linux Gitea runners to build this Windows-targeted project; no-op on Windows. -->
|
||||||
|
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
|
|
||||||
Assert.NotNull(ctx);
|
Assert.NotNull(ctx);
|
||||||
Assert.True(Directory.Exists(ctx.WorktreePath));
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||||
Assert.Equal($"claudedo/{task.Id[..8]}", ctx.BranchName);
|
Assert.Equal($"claudedo/{task.Id.Replace("-", "")}", ctx.BranchName);
|
||||||
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
|
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
|
||||||
|
|
||||||
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
var row = await wtRepo.GetByTaskIdAsync(task.Id);
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
var runRepo = new TaskRunRepository(_db.Factory);
|
var runRepo = new TaskRunRepository(_db.Factory);
|
||||||
var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger<WorktreeManager>.Instance);
|
var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
var argsBuilder = new ClaudeArgsBuilder();
|
var argsBuilder = new ClaudeArgsBuilder();
|
||||||
var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, broadcaster, wtManager, argsBuilder, _cfg,
|
var subtaskRepo = new SubtaskRepository(_db.Factory);
|
||||||
|
var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, subtaskRepo, broadcaster, wtManager, argsBuilder, _cfg,
|
||||||
NullLogger<TaskRunner>.Instance);
|
NullLogger<TaskRunner>.Instance);
|
||||||
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance);
|
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance);
|
||||||
return (service, fake);
|
return (service, fake);
|
||||||
|
|||||||
Reference in New Issue
Block a user