Compare commits
12 Commits
3423919655
...
2a8cd97d02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a8cd97d02 | ||
|
|
09e8b1f10b | ||
|
|
92d8d902df | ||
|
|
aa1008dcff | ||
|
|
5f3d41e1f6 | ||
|
|
7d48f34b15 | ||
|
|
51a1bbe6b8 | ||
|
|
ad7c9facaf | ||
|
|
11a4376da5 | ||
|
|
f10ad69863 | ||
|
|
dc4571a338 | ||
|
|
4fb6ba6be8 |
@@ -110,14 +110,16 @@ public partial class App : Application
|
|||||||
sc.AddSingleton<IInstallStep, WriteConfigStep>();
|
sc.AddSingleton<IInstallStep, WriteConfigStep>();
|
||||||
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
||||||
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
|
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
|
||||||
|
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartServiceStep>());
|
||||||
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
||||||
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
|
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
|
||||||
sc.AddSingleton<WriteInstallManifestStep>();
|
sc.AddSingleton<WriteInstallManifestStep>();
|
||||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
|
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
|
||||||
|
|
||||||
// Stop/Start — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
|
// Stop — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
|
||||||
// Pulled by Update flow + Repair/Uninstall.
|
// Pulled by Update flow + Repair/Uninstall.
|
||||||
sc.AddSingleton<StopServiceStep>();
|
sc.AddSingleton<StopServiceStep>();
|
||||||
|
// StartServiceStep is also registered as IInstallStep above (fresh-install pipeline).
|
||||||
sc.AddSingleton<StartServiceStep>();
|
sc.AddSingleton<StartServiceStep>();
|
||||||
|
|
||||||
// Runners
|
// Runners
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public sealed class UninstallRunner
|
|||||||
_stopService = stopService;
|
_stopService = stopService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StepResult> RunAsync(IProgress<string> progress, CancellationToken ct)
|
public async Task<StepResult> RunAsync(bool removeAppData, IProgress<string> progress, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// 1) Validate install dir up front — refuse obviously unsafe paths.
|
// 1) Validate install dir up front — refuse obviously unsafe paths.
|
||||||
// Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\.
|
// Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\.
|
||||||
@@ -67,7 +67,9 @@ public sealed class UninstallRunner
|
|||||||
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
|
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) Delete ~/.todo-app (config + DB + logs) — user opted into full removal.
|
// 6) Delete ~/.todo-app (config + DB + logs) — only if user opted in.
|
||||||
|
if (removeAppData)
|
||||||
|
{
|
||||||
var appData = Paths.AppDataRoot();
|
var appData = Paths.AppDataRoot();
|
||||||
if (Directory.Exists(appData))
|
if (Directory.Exists(appData))
|
||||||
{
|
{
|
||||||
@@ -75,6 +77,7 @@ public sealed class UninstallRunner
|
|||||||
if (!TryDeleteDir(appData, out var err))
|
if (!TryDeleteDir(appData, out var err))
|
||||||
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),
|
// 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
|
// our own exe is still locked — schedule a cmd.exe trampoline to finish
|
||||||
|
|||||||
@@ -70,11 +70,16 @@ public sealed class DownloadAndExtractStep : IInstallStep
|
|||||||
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
|
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
|
||||||
|
|
||||||
// Only after verification do we touch the install directory.
|
// Only after verification do we touch the install directory.
|
||||||
progress.Report("Clearing previous app/worker binaries...");
|
progress.Report("Stashing previous app/worker binaries...");
|
||||||
var appDest = Path.Combine(ctx.InstallDirectory, "app");
|
var appDest = Path.Combine(ctx.InstallDirectory, "app");
|
||||||
var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
|
var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
|
||||||
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
|
var appBak = appDest + ".bak";
|
||||||
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
|
var workerBak = workerDest + ".bak";
|
||||||
|
|
||||||
|
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
|
||||||
|
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true);
|
||||||
|
if (Directory.Exists(appDest)) Directory.Move(appDest, appBak);
|
||||||
|
if (Directory.Exists(workerDest)) Directory.Move(workerDest, workerBak);
|
||||||
|
|
||||||
progress.Report("Extracting...");
|
progress.Report("Extracting...");
|
||||||
Directory.CreateDirectory(ctx.InstallDirectory);
|
Directory.CreateDirectory(ctx.InstallDirectory);
|
||||||
@@ -84,11 +89,19 @@ public sealed class DownloadAndExtractStep : IInstallStep
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
// Roll back to previous binaries.
|
||||||
|
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
|
||||||
|
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
|
||||||
|
if (Directory.Exists(appBak)) Directory.Move(appBak, appDest);
|
||||||
|
if (Directory.Exists(workerBak)) Directory.Move(workerBak, workerDest);
|
||||||
return StepResult.Fail(
|
return StepResult.Fail(
|
||||||
$"Extraction failed after old binaries were removed: {ex.Message}. " +
|
$"Extraction failed; previous binaries have been restored: {ex.Message}.");
|
||||||
"Your install directory may be incomplete. Re-run the installer to retry.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Success — drop stash.
|
||||||
|
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
|
||||||
|
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true);
|
||||||
|
|
||||||
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
|
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
|
||||||
return StepResult.Ok();
|
return StepResult.Ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,10 +46,9 @@ public sealed class RegisterServiceStep : IInstallStep
|
|||||||
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
|
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
|
||||||
|
|
||||||
if (ctx.ServiceAccount == "CurrentUser")
|
if (ctx.ServiceAccount == "CurrentUser")
|
||||||
{
|
return StepResult.Fail(
|
||||||
var username = Environment.UserName;
|
"Service cannot run as Current User without a password. " +
|
||||||
createArgs += $" obj= \".\\{username}\"";
|
"Select 'Local System' or extend ServicePage to capture a password.");
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
@@ -68,15 +67,6 @@ public sealed class RegisterServiceStep : IInstallStep
|
|||||||
if (failExit != 0)
|
if (failExit != 0)
|
||||||
progress.Report($"Warning: failed to set restart policy (exit {failExit})");
|
progress.Report($"Warning: failed to set restart policy (exit {failExit})");
|
||||||
|
|
||||||
// Start service if auto-start
|
|
||||||
if (ctx.AutoStart)
|
|
||||||
{
|
|
||||||
progress.Report("Starting service...");
|
|
||||||
var (startExit, _) = await RunSc($"start {ServiceName}", ctx, progress, ct);
|
|
||||||
if (startExit != 0)
|
|
||||||
progress.Report("Warning: service created but failed to start. You may need to start it manually.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return StepResult.Ok();
|
return StepResult.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public sealed class WriteConfigStep : IInstallStep
|
|||||||
|
|
||||||
var uiCfg = new InstallerAppSettings
|
var uiCfg = new InstallerAppSettings
|
||||||
{
|
{
|
||||||
DbPath = ctx.UiDbPath,
|
DbPath = Paths.Expand(ctx.UiDbPath),
|
||||||
SignalRUrl = ctx.SignalRUrl,
|
SignalRUrl = ctx.SignalRUrl,
|
||||||
};
|
};
|
||||||
uiCfg.Save();
|
uiCfg.Save();
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _versionLabel = "";
|
private string _versionLabel = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _removeAppData;
|
||||||
|
|
||||||
public SettingsViewModel(
|
public SettingsViewModel(
|
||||||
PageResolver resolver,
|
PageResolver resolver,
|
||||||
InstallContext context,
|
InstallContext context,
|
||||||
@@ -133,8 +136,12 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task Uninstall()
|
private async Task Uninstall()
|
||||||
{
|
{
|
||||||
|
var dataNote = RemoveAppData
|
||||||
|
? "This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?"
|
||||||
|
: "This will remove ClaudeDo. Your tasks, configuration, and database in ~/.todo-app will be kept.\n\nContinue?";
|
||||||
|
|
||||||
var confirm = MessageBox.Show(
|
var confirm = MessageBox.Show(
|
||||||
"This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?",
|
dataNote,
|
||||||
"Uninstall ClaudeDo",
|
"Uninstall ClaudeDo",
|
||||||
MessageBoxButton.YesNo,
|
MessageBoxButton.YesNo,
|
||||||
MessageBoxImage.Warning);
|
MessageBoxImage.Warning);
|
||||||
@@ -142,7 +149,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
if (confirm != MessageBoxResult.Yes) return;
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
var progress = new Progress<string>(msg => StatusMessage = msg);
|
var progress = new Progress<string>(msg => StatusMessage = msg);
|
||||||
var r = await _uninstallRunner.RunAsync(progress, CancellationToken.None);
|
var r = await _uninstallRunner.RunAsync(RemoveAppData, progress, CancellationToken.None);
|
||||||
|
|
||||||
if (!r.Success)
|
if (!r.Success)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- Status message / version label -->
|
<!-- Status message / version label -->
|
||||||
@@ -88,14 +89,17 @@
|
|||||||
</TextBlock>
|
</TextBlock>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Button Grid.Column="1" Content="Uninstall" Margin="0,0,8,0"
|
<CheckBox Grid.Column="1" IsChecked="{Binding RemoveAppData}"
|
||||||
|
Content="Remove user data (tasks, logs, configs in ~/.todo-app)"
|
||||||
|
Margin="0,0,12,0" VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="2" Content="Uninstall" Margin="0,0,8,0"
|
||||||
Command="{Binding UninstallCommand}"/>
|
Command="{Binding UninstallCommand}"/>
|
||||||
<Button Grid.Column="2" Content="Repair" Margin="0,0,8,0"
|
<Button Grid.Column="3" Content="Repair" Margin="0,0,8,0"
|
||||||
Command="{Binding RepairCommand}"/>
|
Command="{Binding RepairCommand}"/>
|
||||||
<Button Grid.Column="3" Content="Save" Margin="0,0,8,0"
|
<Button Grid.Column="4" Content="Save" Margin="0,0,8,0"
|
||||||
Command="{Binding SaveCommand}"
|
Command="{Binding SaveCommand}"
|
||||||
Style="{StaticResource AccentButton}"/>
|
Style="{StaticResource AccentButton}"/>
|
||||||
<Button Grid.Column="4" Content="Close"
|
<Button Grid.Column="5" Content="Close"
|
||||||
Command="{Binding CloseCommand}"/>
|
Command="{Binding CloseCommand}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ public partial class ListEditorViewModel : ViewModelBase
|
|||||||
|
|
||||||
public void InitForCreate()
|
public void InitForCreate()
|
||||||
{
|
{
|
||||||
|
_tcs = new TaskCompletionSource<ListEntity?>();
|
||||||
_editId = null;
|
_editId = null;
|
||||||
_createdAt = DateTime.UtcNow;
|
_createdAt = DateTime.UtcNow;
|
||||||
WindowTitle = "New List";
|
WindowTitle = "New List";
|
||||||
@@ -61,6 +62,7 @@ public partial class ListEditorViewModel : ViewModelBase
|
|||||||
|
|
||||||
public void InitForEdit(ListEntity entity, ListConfigEntity? config)
|
public void InitForEdit(ListEntity entity, ListConfigEntity? config)
|
||||||
{
|
{
|
||||||
|
_tcs = new TaskCompletionSource<ListEntity?>();
|
||||||
_editId = entity.Id;
|
_editId = entity.Id;
|
||||||
_createdAt = entity.CreatedAt;
|
_createdAt = entity.CreatedAt;
|
||||||
Name = entity.Name;
|
Name = entity.Name;
|
||||||
@@ -119,9 +121,5 @@ public partial class ListEditorViewModel : ViewModelBase
|
|||||||
_tcs.TrySetResult(null);
|
_tcs.TrySetResult(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<ListEntity?> ShowAndWaitAsync()
|
public Task<ListEntity?> ShowAndWaitAsync() => _tcs.Task;
|
||||||
{
|
|
||||||
_tcs = new TaskCompletionSource<ListEntity?>();
|
|
||||||
return _tcs.Task;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
var ct = cts.Token;
|
var ct = cts.Token;
|
||||||
|
|
||||||
_taskId = taskId;
|
_taskId = taskId;
|
||||||
|
HasWorktree = false;
|
||||||
|
WorktreeState = "";
|
||||||
|
BranchName = null;
|
||||||
|
DiffStat = null;
|
||||||
|
WorktreePath = null;
|
||||||
|
OnPropertyChanged(nameof(CanWorktreeAction));
|
||||||
LiveText = "";
|
LiveText = "";
|
||||||
_formatter = new StreamLineFormatter();
|
_formatter = new StreamLineFormatter();
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
|
|
||||||
public void InitForCreate(string listId, string defaultCommitType = "chore")
|
public void InitForCreate(string listId, string defaultCommitType = "chore")
|
||||||
{
|
{
|
||||||
|
_tcs = new TaskCompletionSource<TaskEntity?>();
|
||||||
_editId = null;
|
_editId = null;
|
||||||
_listId = listId;
|
_listId = listId;
|
||||||
_createdAt = DateTime.UtcNow;
|
_createdAt = DateTime.UtcNow;
|
||||||
@@ -81,6 +82,7 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
|
|
||||||
public async Task InitForEditAsync(TaskEntity entity, IReadOnlyList<TagEntity> taskTags, CancellationToken ct = default)
|
public async Task InitForEditAsync(TaskEntity entity, IReadOnlyList<TagEntity> taskTags, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
_tcs = new TaskCompletionSource<TaskEntity?>();
|
||||||
_editId = entity.Id;
|
_editId = entity.Id;
|
||||||
_listId = entity.ListId;
|
_listId = entity.ListId;
|
||||||
_createdAt = entity.CreatedAt;
|
_createdAt = entity.CreatedAt;
|
||||||
@@ -128,6 +130,7 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
// Keep old sync overload for callers that haven't loaded agents yet
|
// 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)
|
||||||
{
|
{
|
||||||
|
_tcs = new TaskCompletionSource<TaskEntity?>();
|
||||||
_editId = entity.Id;
|
_editId = entity.Id;
|
||||||
_listId = entity.ListId;
|
_listId = entity.ListId;
|
||||||
_createdAt = entity.CreatedAt;
|
_createdAt = entity.CreatedAt;
|
||||||
@@ -257,9 +260,5 @@ public partial class TaskEditorViewModel : ViewModelBase
|
|||||||
_tcs.TrySetResult(null);
|
_tcs.TrySetResult(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<TaskEntity?> ShowAndWaitAsync()
|
public Task<TaskEntity?> ShowAndWaitAsync() => _tcs.Task;
|
||||||
{
|
|
||||||
_tcs = new TaskCompletionSource<TaskEntity?>();
|
|
||||||
return _tcs.Task;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,17 +157,20 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
[RelayCommand(CanExecute = nameof(CanAddTask))]
|
[RelayCommand(CanExecute = nameof(CanAddTask))]
|
||||||
private async Task AddTask()
|
private async Task AddTask()
|
||||||
{
|
{
|
||||||
|
var listId = CurrentListId;
|
||||||
|
if (listId is null) return;
|
||||||
|
|
||||||
string defaultCommitType;
|
string defaultCommitType;
|
||||||
using (var context = _dbFactory.CreateDbContext())
|
using (var context = _dbFactory.CreateDbContext())
|
||||||
{
|
{
|
||||||
var listRepo = new ListRepository(context);
|
var listRepo = new ListRepository(context);
|
||||||
var list = await listRepo.GetByIdAsync(CurrentListId);
|
var list = await listRepo.GetByIdAsync(listId);
|
||||||
defaultCommitType = list?.DefaultCommitType ?? "chore";
|
defaultCommitType = list?.DefaultCommitType ?? "chore";
|
||||||
}
|
}
|
||||||
|
|
||||||
var editor = _editorFactory();
|
var editor = _editorFactory();
|
||||||
await editor.LoadAgentsAsync(_worker);
|
await editor.LoadAgentsAsync(_worker);
|
||||||
editor.InitForCreate(CurrentListId, defaultCommitType);
|
editor.InitForCreate(listId, defaultCommitType);
|
||||||
|
|
||||||
var window = new TaskEditorView { DataContext = editor };
|
var window = new TaskEditorView { DataContext = editor };
|
||||||
editor.RequestClose += () => window.Close();
|
editor.RequestClose += () => window.Close();
|
||||||
@@ -328,8 +331,15 @@ public partial class TaskListViewModel : ViewModelBase
|
|||||||
private async void OnTaskUpdated(string taskId)
|
private async void OnTaskUpdated(string taskId)
|
||||||
{
|
{
|
||||||
if (CurrentListId is null) return;
|
if (CurrentListId is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
await RefreshSingleAsync(taskId);
|
await RefreshSingleAsync(taskId);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine($"[TaskListViewModel] OnTaskUpdated failed for {taskId}: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task ShowDialogAsync(Window dialog)
|
private static async Task ShowDialogAsync(Window dialog)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _queue.RunNow(taskId);
|
await _queue.RunNow(taskId);
|
||||||
await _broadcaster.RunCreated(taskId, 1, false);
|
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException)
|
catch (InvalidOperationException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -55,9 +55,15 @@ public sealed class ClaudeArgsBuilder
|
|||||||
|
|
||||||
private static string Escape(string value)
|
private static string Escape(string value)
|
||||||
{
|
{
|
||||||
if (value.Contains(' ') || value.Contains('"') || value.Contains('\''))
|
if (value.Contains(' ') || value.Contains('"') || value.Contains('\'')
|
||||||
|
|| value.Contains('\t') || value.Contains('\n') || value.Contains('\r'))
|
||||||
{
|
{
|
||||||
var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
var escaped = value
|
||||||
|
.Replace("\\", "\\\\")
|
||||||
|
.Replace("\"", "\\\"")
|
||||||
|
.Replace("\n", "\\n")
|
||||||
|
.Replace("\r", "\\r")
|
||||||
|
.Replace("\t", "\\t");
|
||||||
return $"\"{escaped}\"";
|
return $"\"{escaped}\"";
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ public sealed class TaskRunner
|
|||||||
var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
|
var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
|
||||||
var retryPrompt = $"The previous attempt failed with:\n\n{result.ErrorMarkdown}\n\nTry again and fix the issues.";
|
var retryPrompt = $"The previous attempt failed with:\n\n{result.ErrorMarkdown}\n\nTry again and fix the issues.";
|
||||||
|
|
||||||
await _broadcaster.RunCreated(task.Id, 2, true);
|
|
||||||
var retryResult = await RunOnceAsync(task.Id, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
|
var retryResult = await RunOnceAsync(task.Id, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
|
||||||
|
|
||||||
if (retryResult.IsSuccess)
|
if (retryResult.IsSuccess)
|
||||||
@@ -216,7 +215,6 @@ public sealed class TaskRunner
|
|||||||
await _broadcaster.TaskStarted(slot, taskId, now);
|
await _broadcaster.TaskStarted(slot, taskId, now);
|
||||||
|
|
||||||
var nextRunNumber = lastRun.RunNumber + 1;
|
var nextRunNumber = lastRun.RunNumber + 1;
|
||||||
await _broadcaster.RunCreated(taskId, nextRunNumber, false);
|
|
||||||
var result = await RunOnceAsync(taskId, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
|
var result = await RunOnceAsync(taskId, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
|
||||||
|
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
@@ -255,6 +253,8 @@ public sealed class TaskRunner
|
|||||||
await runRepo.AddAsync(run, ct);
|
await runRepo.AddAsync(run, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _broadcaster.RunCreated(taskId, runNumber, isRetry);
|
||||||
|
|
||||||
var arguments = _argsBuilder.Build(config);
|
var arguments = _argsBuilder.Build(config);
|
||||||
|
|
||||||
await using var logWriter = new LogWriter(logPath);
|
await using var logWriter = new LogWriter(logPath);
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ public sealed class QueueService : BackgroundService
|
|||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
|
if (_queueSlot?.TaskId == taskId)
|
||||||
|
throw new InvalidOperationException("task is already running in queue slot");
|
||||||
if (_overrideSlot is not null)
|
if (_overrideSlot is not null)
|
||||||
throw new InvalidOperationException("override slot busy");
|
throw new InvalidOperationException("override slot busy");
|
||||||
|
|
||||||
@@ -92,10 +94,12 @@ public sealed class QueueService : BackgroundService
|
|||||||
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||||
|
|
||||||
if (task.Status == Data.Models.TaskStatus.Running)
|
if (task.Status == Data.Models.TaskStatus.Running)
|
||||||
throw new InvalidOperationException("Task is currently running.");
|
throw new InvalidOperationException("task is already running");
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
|
if (_queueSlot?.TaskId == taskId)
|
||||||
|
throw new InvalidOperationException("task is already running in queue slot");
|
||||||
if (_overrideSlot is not null)
|
if (_overrideSlot is not null)
|
||||||
throw new InvalidOperationException("override slot busy");
|
throw new InvalidOperationException("override slot busy");
|
||||||
|
|
||||||
|
|||||||
@@ -68,4 +68,28 @@ public sealed class ClaudeArgsBuilderTests
|
|||||||
var args = _builder.Build(new ClaudeRunConfig(null, """Don't say "hello".""", null, null));
|
var args = _builder.Build(new ClaudeRunConfig(null, """Don't say "hello".""", null, null));
|
||||||
Assert.Contains("--append-system-prompt", args);
|
Assert.Contains("--append-system-prompt", args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_quotes_system_prompt_with_newline()
|
||||||
|
{
|
||||||
|
var args = _builder.Build(new ClaudeRunConfig(
|
||||||
|
Model: null,
|
||||||
|
SystemPrompt: "line1\nline2",
|
||||||
|
AgentPath: null,
|
||||||
|
ResumeSessionId: null));
|
||||||
|
|
||||||
|
Assert.Contains("--append-system-prompt \"line1\\nline2\"", args);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_quotes_system_prompt_with_tab()
|
||||||
|
{
|
||||||
|
var args = _builder.Build(new ClaudeRunConfig(
|
||||||
|
Model: null,
|
||||||
|
SystemPrompt: "col1\tcol2",
|
||||||
|
AgentPath: null,
|
||||||
|
ResumeSessionId: null));
|
||||||
|
|
||||||
|
Assert.Contains("\"col1", args);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using ClaudeDo.Worker.Services;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Services;
|
||||||
|
|
||||||
|
public sealed class QueueServiceSlotGuardTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _taskRepo;
|
||||||
|
private readonly ListRepository _listRepo;
|
||||||
|
private readonly TagRepository _tagRepo;
|
||||||
|
private readonly WorkerConfig _cfg;
|
||||||
|
private readonly string _tempDir;
|
||||||
|
|
||||||
|
public QueueServiceSlotGuardTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_taskRepo = new TaskRepository(_ctx);
|
||||||
|
_listRepo = new ListRepository(_ctx);
|
||||||
|
_tagRepo = new TagRepository(_ctx);
|
||||||
|
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_slotguard_{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
_cfg = new WorkerConfig
|
||||||
|
{
|
||||||
|
SandboxRoot = Path.Combine(_tempDir, "sandbox"),
|
||||||
|
LogRoot = Path.Combine(_tempDir, "logs"),
|
||||||
|
QueueBackstopIntervalMs = 50,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
try { Directory.Delete(_tempDir, true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private (QueueService service, FakeClaudeProcess fakeProcess) CreateService(
|
||||||
|
Func<string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null)
|
||||||
|
{
|
||||||
|
var fake = new FakeClaudeProcess(handler);
|
||||||
|
var broadcaster = new HubBroadcaster(new FakeHubContext());
|
||||||
|
var dbFactory = _db.CreateFactory();
|
||||||
|
var wtManager = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
|
var argsBuilder = new ClaudeArgsBuilder();
|
||||||
|
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
|
||||||
|
NullLogger<TaskRunner>.Instance);
|
||||||
|
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance);
|
||||||
|
return (service, fake);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> SeedListWithAgentTagAsync()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
await _listRepo.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow });
|
||||||
|
var tags = await _tagRepo.GetAllAsync();
|
||||||
|
var agentTag = tags.First(t => t.Name == "agent");
|
||||||
|
await _listRepo.AddTagAsync(listId, agentTag.Id);
|
||||||
|
return listId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TaskEntity> SeedQueuedTaskAsync(string listId)
|
||||||
|
{
|
||||||
|
var task = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "Guard test task",
|
||||||
|
Description = "Test",
|
||||||
|
Status = TaskStatus.Queued,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
await _taskRepo.AddAsync(task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunNow_Throws_When_Task_Already_Running_In_Queue_Slot()
|
||||||
|
{
|
||||||
|
var listId = await SeedListWithAgentTagAsync();
|
||||||
|
var task = await SeedQueuedTaskAsync(listId);
|
||||||
|
|
||||||
|
// Gate keeps the queue slot occupied indefinitely.
|
||||||
|
var tcs = new TaskCompletionSource<RunResult>();
|
||||||
|
var queuePickedUp = new TaskCompletionSource();
|
||||||
|
|
||||||
|
var (service, _) = CreateService(async (_, _, _, _, ct) =>
|
||||||
|
{
|
||||||
|
queuePickedUp.TrySetResult();
|
||||||
|
return await tcs.Task;
|
||||||
|
});
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await service.StartAsync(cts.Token);
|
||||||
|
service.WakeQueue();
|
||||||
|
|
||||||
|
// Wait until the queue slot has actually picked up the task.
|
||||||
|
await queuePickedUp.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
// Now the same taskId is in the queue slot — RunNow must reject it.
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.RunNow(task.Id));
|
||||||
|
Assert.Contains("already running", ex.Message);
|
||||||
|
|
||||||
|
tcs.SetResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" });
|
||||||
|
cts.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContinueTask_Throws_When_Task_Already_Running_In_Queue_Slot()
|
||||||
|
{
|
||||||
|
var listId = await SeedListWithAgentTagAsync();
|
||||||
|
var task = await SeedQueuedTaskAsync(listId);
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource<RunResult>();
|
||||||
|
var queuePickedUp = new TaskCompletionSource();
|
||||||
|
|
||||||
|
var (service, _) = CreateService(async (_, _, _, _, ct) =>
|
||||||
|
{
|
||||||
|
queuePickedUp.TrySetResult();
|
||||||
|
return await tcs.Task;
|
||||||
|
});
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await service.StartAsync(cts.Token);
|
||||||
|
service.WakeQueue();
|
||||||
|
|
||||||
|
await queuePickedUp.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
service.ContinueTask(task.Id, "follow-up"));
|
||||||
|
Assert.Contains("already running", ex.Message);
|
||||||
|
|
||||||
|
tcs.SetResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" });
|
||||||
|
cts.Cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user