feat(worker): batch MCP tools for the external endpoint
Add seven best-effort batch variants of the single-entity external MCP tools: batch_get_tasks, batch_add_tasks, batch_update_task_status, batch_cancel_tasks, batch_delete_tasks, batch_set_my_day, and batch_cleanup_task_worktrees. Each loops the existing ExternalMcpService methods sequentially (scoped DbContext is not thread-safe), returns a per-item result array so a failing item never aborts the rest, and rejects empty or over-100-item batches. Merge/review stay single-task.
This commit is contained in:
@@ -34,6 +34,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
|
|||||||
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
||||||
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern:
|
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern:
|
||||||
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`
|
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`
|
||||||
|
- `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items.
|
||||||
- `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList`
|
- `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList`
|
||||||
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
|
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
|
||||||
- `RunHistoryMcpTools` — `ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
|
- `RunHistoryMcpTools` — `ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
|
||||||
|
|||||||
225
src/ClaudeDo.Worker/External/BatchMcpTools.cs
vendored
Normal file
225
src/ClaudeDo.Worker/External/BatchMcpTools.cs
vendored
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.External;
|
||||||
|
|
||||||
|
public sealed record BatchAddTaskInput(string Title, string? Description = null, string? Model = null);
|
||||||
|
public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null);
|
||||||
|
|
||||||
|
public sealed record BatchGetTaskResult(string Id, bool Found, TaskDto? Task, string? Error);
|
||||||
|
public sealed record BatchAddTaskResult(int Index, string Title, bool Ok, TaskDto? Task, string? Error);
|
||||||
|
public sealed record BatchTaskResult(string TaskId, bool Ok, string? Error);
|
||||||
|
public sealed record BatchCancelResult(string TaskId, bool Ok, bool Cancelled, string? Error);
|
||||||
|
public sealed record BatchCleanupResult(string TaskId, bool Ok, bool Removed, bool BranchDeleted, string? Error);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch variants of the single-entity tools on <see cref="ExternalMcpService"/>.
|
||||||
|
/// Every tool is best-effort: each item is attempted independently and reported in a
|
||||||
|
/// per-item result array (a failing item never aborts the rest). Operations run
|
||||||
|
/// sequentially — the underlying repositories share one scoped DbContext, which is not
|
||||||
|
/// thread-safe. Merge/review stay single-task (conflicts need the interactive resolver).
|
||||||
|
/// </summary>
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class BatchMcpTools
|
||||||
|
{
|
||||||
|
private const int MaxBatchSize = 100;
|
||||||
|
|
||||||
|
private readonly ExternalMcpService _svc;
|
||||||
|
|
||||||
|
public BatchMcpTools(ExternalMcpService svc) => _svc = svc;
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Fetch a snapshot of many tasks in one call (overview / polling a fan-out). " +
|
||||||
|
"Returns one result per id: { id, found, task, error }. A missing id is found=false " +
|
||||||
|
"(not an error); error is only set for an unexpected failure. Max 100 ids.")]
|
||||||
|
public async Task<IReadOnlyList<BatchGetTaskResult>> BatchGetTasks(
|
||||||
|
string[] taskIds, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureWithinCap(taskIds, nameof(taskIds));
|
||||||
|
|
||||||
|
var results = new List<BatchGetTaskResult>(taskIds.Length);
|
||||||
|
foreach (var id in taskIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var task = await _svc.GetTask(id, cancellationToken);
|
||||||
|
results.Add(new BatchGetTaskResult(id, true, task, null));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
results.Add(new BatchGetTaskResult(id, false, null, null));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results.Add(new BatchGetTaskResult(id, false, null, ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Create many tasks in one list at once. Each item: { title, description?, model? } " +
|
||||||
|
"(model: haiku|sonnet|opus, blank = inherit list/global default). " +
|
||||||
|
"queueImmediately enqueues every created task. " +
|
||||||
|
"Returns one result per item: { index, title, ok, task, error }. Max 100 items.")]
|
||||||
|
public async Task<IReadOnlyList<BatchAddTaskResult>> BatchAddTasks(
|
||||||
|
string listId,
|
||||||
|
BatchAddTaskInput[] tasks,
|
||||||
|
string? createdBy = null,
|
||||||
|
bool queueImmediately = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
EnsureWithinCap(tasks, nameof(tasks));
|
||||||
|
|
||||||
|
var results = new List<BatchAddTaskResult>(tasks.Length);
|
||||||
|
for (var i = 0; i < tasks.Length; i++)
|
||||||
|
{
|
||||||
|
var item = tasks[i];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var created = await _svc.AddTask(
|
||||||
|
listId, item.Title, item.Description, createdBy,
|
||||||
|
queueImmediately, item.Model, cancellationToken);
|
||||||
|
results.Add(new BatchAddTaskResult(i, item.Title, true, created, null));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results.Add(new BatchAddTaskResult(i, item.Title, false, null, ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Set the status of many tasks at once. status is 'Idle' (reset to editable) or " +
|
||||||
|
"'Queued' (enqueue for execution) only — same rule as update_task_status. " +
|
||||||
|
"Returns one result per id: { taskId, ok, error }. Max 100 ids.")]
|
||||||
|
public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus(
|
||||||
|
string[] taskIds, string status, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureWithinCap(taskIds, nameof(taskIds));
|
||||||
|
return await RunPerTaskAsync(taskIds,
|
||||||
|
(id, ct) => _svc.UpdateTaskStatus(id, status, ct), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Cancel many running tasks at once. Returns one result per id: " +
|
||||||
|
"{ taskId, ok, cancelled, error }. cancelled=false means the task was not running. " +
|
||||||
|
"Max 100 ids.")]
|
||||||
|
public async Task<IReadOnlyList<BatchCancelResult>> BatchCancelTasks(
|
||||||
|
string[] taskIds, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureWithinCap(taskIds, nameof(taskIds));
|
||||||
|
|
||||||
|
var results = new List<BatchCancelResult>(taskIds.Length);
|
||||||
|
foreach (var id in taskIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = await _svc.CancelTask(id, cancellationToken);
|
||||||
|
results.Add(new BatchCancelResult(id, true, r.Cancelled, null));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results.Add(new BatchCancelResult(id, false, false, ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Delete many tasks at once. A Running task is refused (cancel it first) and reported " +
|
||||||
|
"as ok=false with its error. Returns one result per id: { taskId, ok, error }. Max 100 ids.")]
|
||||||
|
public async Task<IReadOnlyList<BatchTaskResult>> BatchDeleteTasks(
|
||||||
|
string[] taskIds, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureWithinCap(taskIds, nameof(taskIds));
|
||||||
|
return await RunPerTaskAsync(taskIds,
|
||||||
|
(id, ct) => _svc.DeleteTask(id, ct), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Daily prep: set/clear MyDay for many tasks at once. Each item: { taskId, isMyDay, sortOrder? }. " +
|
||||||
|
"Still cap-guarded — items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " +
|
||||||
|
"(ok=false) without blocking the rest. Returns one result per item: { taskId, ok, error }. Max 100 items.")]
|
||||||
|
public async Task<IReadOnlyList<BatchTaskResult>> BatchSetMyDay(
|
||||||
|
BatchSetMyDayInput[] items, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureWithinCap(items, nameof(items));
|
||||||
|
|
||||||
|
var results = new List<BatchTaskResult>(items.Length);
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _svc.SetMyDay(item.TaskId, item.IsMyDay, item.SortOrder, cancellationToken);
|
||||||
|
results.Add(new BatchTaskResult(item.TaskId, true, null));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results.Add(new BatchTaskResult(item.TaskId, false, ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Remove the worktrees of many tasks at once (directory + git branch). " +
|
||||||
|
"force=false refuses a dirty or Running worktree (reported ok=false); force=true removes " +
|
||||||
|
"even a dirty worktree (uncommitted changes lost), still refusing Running tasks. " +
|
||||||
|
"Returns one result per id: { taskId, ok, removed, branchDeleted, error }. Max 100 ids.")]
|
||||||
|
public async Task<IReadOnlyList<BatchCleanupResult>> BatchCleanupTaskWorktrees(
|
||||||
|
string[] taskIds, bool force = false, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
EnsureWithinCap(taskIds, nameof(taskIds));
|
||||||
|
|
||||||
|
var results = new List<BatchCleanupResult>(taskIds.Length);
|
||||||
|
foreach (var id in taskIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = await _svc.CleanupTaskWorktree(id, force, cancellationToken);
|
||||||
|
results.Add(new BatchCleanupResult(id, true, r.Removed, r.BranchDeleted, null));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results.Add(new BatchCleanupResult(id, false, false, false, ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<BatchTaskResult>> RunPerTaskAsync(
|
||||||
|
string[] taskIds, Func<string, CancellationToken, Task> op, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var results = new List<BatchTaskResult>(taskIds.Length);
|
||||||
|
foreach (var id in taskIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await op(id, cancellationToken);
|
||||||
|
results.Add(new BatchTaskResult(id, true, null));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results.Add(new BatchTaskResult(id, false, ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureWithinCap<T>(IReadOnlyCollection<T>? items, string name)
|
||||||
|
{
|
||||||
|
if (items is null || items.Count == 0)
|
||||||
|
throw new InvalidOperationException($"{name} is required and must contain at least one item.");
|
||||||
|
if (items.Count > MaxBatchSize)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Batch too large: {items.Count} items (max {MaxBatchSize}). Split into smaller batches.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -264,6 +264,7 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeMaintenanceService>());
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeMaintenanceService>());
|
||||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskMergeService>());
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskMergeService>());
|
||||||
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
||||||
|
externalBuilder.Services.AddScoped<BatchMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<ListMcpTools>();
|
externalBuilder.Services.AddScoped<ListMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<ConfigMcpTools>();
|
externalBuilder.Services.AddScoped<ConfigMcpTools>();
|
||||||
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
|
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
|
||||||
@@ -276,6 +277,7 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
externalBuilder.Services.AddMcpServer()
|
externalBuilder.Services.AddMcpServer()
|
||||||
.WithHttpTransport()
|
.WithHttpTransport()
|
||||||
.WithTools<ExternalMcpService>()
|
.WithTools<ExternalMcpService>()
|
||||||
|
.WithTools<BatchMcpTools>()
|
||||||
.WithTools<ListMcpTools>()
|
.WithTools<ListMcpTools>()
|
||||||
.WithTools<ConfigMcpTools>()
|
.WithTools<ConfigMcpTools>()
|
||||||
.WithTools<RunHistoryMcpTools>()
|
.WithTools<RunHistoryMcpTools>()
|
||||||
|
|||||||
204
tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs
vendored
Normal file
204
tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs
vendored
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.External;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Lifecycle;
|
||||||
|
using ClaudeDo.Worker.Queue;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using ClaudeDo.Worker.Worktrees;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.External;
|
||||||
|
|
||||||
|
public sealed class BatchMcpToolsTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
|
||||||
|
public BatchMcpToolsTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
_broadcaster = new HubBroadcaster(new CapturingHubContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> SeedListAsync()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TaskEntity> SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Idle)
|
||||||
|
{
|
||||||
|
var task = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = title,
|
||||||
|
Status = status,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BatchMcpTools BuildSut()
|
||||||
|
{
|
||||||
|
var git = new GitService();
|
||||||
|
var factory = _db.CreateFactory();
|
||||||
|
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
|
var merge = new TaskMergeService(factory, git, _broadcaster, TaskStateServiceBuilder.Build(factory).State, NullLogger<TaskMergeService>.Instance);
|
||||||
|
var svc = new ExternalMcpService(
|
||||||
|
_tasks, _lists, CreateQueue(), _broadcaster,
|
||||||
|
TaskStateServiceBuilder.Build(factory).State,
|
||||||
|
git, factory, maintenance, merge);
|
||||||
|
return new BatchMcpTools(svc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private QueueService CreateQueue()
|
||||||
|
{
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_batch_{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(tempDir);
|
||||||
|
var cfg = new WorkerConfig
|
||||||
|
{
|
||||||
|
SandboxRoot = Path.Combine(tempDir, "sandbox"),
|
||||||
|
LogRoot = Path.Combine(tempDir, "logs"),
|
||||||
|
QueueBackstopIntervalMs = 50,
|
||||||
|
};
|
||||||
|
var dbFactory = _db.CreateFactory();
|
||||||
|
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
||||||
|
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||||
|
var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, new ClaudeArgsBuilder(), cfg,
|
||||||
|
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore());
|
||||||
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
|
||||||
|
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance,
|
||||||
|
new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BatchAddTasks_CreatesAll_AndReportsOkPerItem()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var sut = BuildSut();
|
||||||
|
|
||||||
|
var results = await sut.BatchAddTasks(listId, new[]
|
||||||
|
{
|
||||||
|
new BatchAddTaskInput("a"),
|
||||||
|
new BatchAddTaskInput("b"),
|
||||||
|
new BatchAddTaskInput("c"),
|
||||||
|
}, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(3, results.Count);
|
||||||
|
Assert.All(results, r => Assert.True(r.Ok));
|
||||||
|
Assert.Equal(new[] { 0, 1, 2 }, results.Select(r => r.Index).ToArray());
|
||||||
|
var inList = await _tasks.GetByListIdAsync(listId);
|
||||||
|
Assert.Equal(3, inList.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BatchAddTasks_FailingItem_DoesNotAbortTheRest()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var sut = BuildSut();
|
||||||
|
|
||||||
|
var results = await sut.BatchAddTasks(listId, new[]
|
||||||
|
{
|
||||||
|
new BatchAddTaskInput("ok-1"),
|
||||||
|
new BatchAddTaskInput(" "), // blank title → AddTask throws
|
||||||
|
new BatchAddTaskInput("ok-2"),
|
||||||
|
}, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(results[0].Ok);
|
||||||
|
Assert.False(results[1].Ok);
|
||||||
|
Assert.NotNull(results[1].Error);
|
||||||
|
Assert.True(results[2].Ok);
|
||||||
|
var inList = await _tasks.GetByListIdAsync(listId);
|
||||||
|
Assert.Equal(2, inList.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BatchGetTasks_MissingId_IsFoundFalseNotError()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var task = await SeedTaskAsync(listId);
|
||||||
|
var sut = BuildSut();
|
||||||
|
|
||||||
|
var results = await sut.BatchGetTasks(new[] { task.Id, "nope" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var found = results.Single(r => r.Id == task.Id);
|
||||||
|
var missing = results.Single(r => r.Id == "nope");
|
||||||
|
Assert.True(found.Found);
|
||||||
|
Assert.NotNull(found.Task);
|
||||||
|
Assert.False(missing.Found);
|
||||||
|
Assert.Null(missing.Task);
|
||||||
|
Assert.Null(missing.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BatchDeleteTasks_RunningTask_ReportedNotOk_OthersDeleted()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var deletable = await SeedTaskAsync(listId, "del", TaskStatus.Idle);
|
||||||
|
var running = await SeedTaskAsync(listId, "run", TaskStatus.Running);
|
||||||
|
var sut = BuildSut();
|
||||||
|
|
||||||
|
var results = await sut.BatchDeleteTasks(new[] { deletable.Id, running.Id }, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(results.Single(r => r.TaskId == deletable.Id).Ok);
|
||||||
|
Assert.False(results.Single(r => r.TaskId == running.Id).Ok);
|
||||||
|
Assert.Null(await _tasks.GetByIdAsync(deletable.Id));
|
||||||
|
Assert.NotNull(await _tasks.GetByIdAsync(running.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BatchUpdateTaskStatus_QueuesIdleTasks()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var t1 = await SeedTaskAsync(listId, "a", TaskStatus.Idle);
|
||||||
|
var t2 = await SeedTaskAsync(listId, "b", TaskStatus.Idle);
|
||||||
|
var sut = BuildSut();
|
||||||
|
|
||||||
|
var results = await sut.BatchUpdateTaskStatus(new[] { t1.Id, t2.Id }, "Queued", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.All(results, r => Assert.True(r.Ok));
|
||||||
|
Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t1.Id))!.Status);
|
||||||
|
Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t2.Id))!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BatchTools_RejectEmptyBatch()
|
||||||
|
{
|
||||||
|
var sut = BuildSut();
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.BatchGetTasks(Array.Empty<string>(), CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BatchTools_RejectOversizedBatch()
|
||||||
|
{
|
||||||
|
var sut = BuildSut();
|
||||||
|
var ids = Enumerable.Range(0, 101).Select(i => i.ToString()).ToArray();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.BatchGetTasks(ids, CancellationToken.None));
|
||||||
|
Assert.Contains("max", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user