724 lines
32 KiB
C#
724 lines
32 KiB
C#
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ClaudeDo.Worker.Lifecycle;
|
|
using ClaudeDo.Worker.Queue;
|
|
using ClaudeDo.Worker.State;
|
|
using ClaudeDo.Worker.Worktrees;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ModelContextProtocol.Server;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.External;
|
|
|
|
public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
|
|
public sealed record DeleteTaskResult(bool Deleted, string Id);
|
|
public sealed record CancelTaskResult(bool Cancelled, string Id);
|
|
public sealed record StatusValueDto(string Status, string Meaning);
|
|
|
|
public sealed record TaskDto(
|
|
string Id,
|
|
string ListId,
|
|
string Title,
|
|
string? Description,
|
|
string Status,
|
|
string? Result,
|
|
string? CreatedBy,
|
|
DateTime CreatedAt,
|
|
DateTime? StartedAt,
|
|
DateTime? FinishedAt,
|
|
bool IsMyDay,
|
|
int SortOrder);
|
|
|
|
public sealed record WorktreeInfoDto(
|
|
string Path, string Branch, string HeadCommit, string BaseCommit,
|
|
int Ahead, int Behind, bool IsDirty);
|
|
|
|
public sealed record TaskDiffDto(
|
|
string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes);
|
|
|
|
public sealed record MergeTaskResultDto(
|
|
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts);
|
|
|
|
public sealed record WorktreeListItemDto(
|
|
string? TaskId, string Path, string Branch,
|
|
string HeadCommit, bool IsDirty, bool MergedIntoMain);
|
|
|
|
public sealed record CleanupWorktreeResult(
|
|
bool Removed, string WorktreePath, bool BranchDeleted);
|
|
|
|
public sealed record DailyPrepCandidateDto(
|
|
string Id, string ListId, string ListName, string Title, string? Description,
|
|
bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);
|
|
|
|
public sealed record DailyPrepDataDto(
|
|
int MaxTasks,
|
|
IReadOnlyList<DailyPrepCandidateDto> Candidates,
|
|
IReadOnlyList<DailyPrepCandidateDto> CurrentMyDay);
|
|
|
|
[McpServerToolType]
|
|
public sealed class ExternalMcpService
|
|
{
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly QueueService _queue;
|
|
private readonly HubBroadcaster _broadcaster;
|
|
private readonly ITaskStateService _state;
|
|
private readonly GitService _git;
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly WorktreeMaintenanceService _maintenance;
|
|
private readonly TaskMergeService _merge;
|
|
|
|
public ExternalMcpService(
|
|
TaskRepository tasks,
|
|
ListRepository lists,
|
|
QueueService queue,
|
|
HubBroadcaster broadcaster,
|
|
ITaskStateService state,
|
|
GitService git,
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
WorktreeMaintenanceService maintenance,
|
|
TaskMergeService merge)
|
|
{
|
|
_tasks = tasks;
|
|
_lists = lists;
|
|
_queue = queue;
|
|
_broadcaster = broadcaster;
|
|
_state = state;
|
|
_git = git;
|
|
_dbFactory = dbFactory;
|
|
_maintenance = maintenance;
|
|
_merge = merge;
|
|
}
|
|
|
|
[McpServerTool, Description("List all task lists available in ClaudeDo.")]
|
|
public async Task<IReadOnlyList<TaskListDto>> ListTaskLists(CancellationToken cancellationToken)
|
|
{
|
|
var lists = await _lists.GetAllAsync(cancellationToken);
|
|
return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList();
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
|
|
"Valid status values: Idle, Queued, Running, WaitingForReview, Done, Failed, Cancelled.")]
|
|
public async Task<IReadOnlyList<TaskDto>> ListTasks(
|
|
string listId,
|
|
string? createdBy,
|
|
string? status,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
TaskStatus? statusFilter = null;
|
|
if (!string.IsNullOrWhiteSpace(status))
|
|
{
|
|
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
|
throw new InvalidOperationException(
|
|
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, Done, Failed, Cancelled.");
|
|
statusFilter = parsed;
|
|
}
|
|
|
|
var tasks = await _tasks.GetByListIdAsync(listId, cancellationToken);
|
|
IEnumerable<TaskEntity> query = tasks;
|
|
if (createdBy is not null)
|
|
query = query.Where(t => t.CreatedBy == createdBy);
|
|
if (statusFilter is not null)
|
|
query = query.Where(t => t.Status == statusFilter);
|
|
|
|
return query.Select(ToDto).ToList();
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Get a single task by id, including its current status and result. " +
|
|
"Status lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
|
|
"A successful run lands in WaitingForReview; use review_task to approve, reject, or cancel. " +
|
|
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution.")]
|
|
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
return ToDto(task);
|
|
}
|
|
|
|
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution.")]
|
|
public async Task<TaskDto> AddTask(
|
|
string listId,
|
|
string title,
|
|
string? description = null,
|
|
string? createdBy = null,
|
|
bool queueImmediately = false,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(listId))
|
|
throw new InvalidOperationException("listId is required.");
|
|
if (string.IsNullOrWhiteSpace(title))
|
|
throw new InvalidOperationException("title is required.");
|
|
|
|
var list = await _lists.GetByIdAsync(listId, cancellationToken)
|
|
?? throw new InvalidOperationException($"List {listId} not found.");
|
|
|
|
var entity = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = title,
|
|
Description = description,
|
|
Status = TaskStatus.Idle,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = list.DefaultCommitType,
|
|
CreatedBy = createdBy.NullIfBlank() ?? "mcp",
|
|
};
|
|
await _tasks.AddAsync(entity, cancellationToken);
|
|
|
|
if (queueImmediately)
|
|
{
|
|
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
|
|
if (!enqueue.Ok)
|
|
throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
|
|
entity.Status = TaskStatus.Queued;
|
|
}
|
|
|
|
await _broadcaster.TaskUpdated(entity.Id);
|
|
return ToDto(entity);
|
|
}
|
|
|
|
[McpServerTool, Description("Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. Refuses if the task is currently Running.")]
|
|
public async Task<TaskDto> UpdateTask(
|
|
string taskId,
|
|
string? title,
|
|
string? description,
|
|
string? commitType,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
if (task.Status == TaskStatus.Running)
|
|
throw new InvalidOperationException("Cannot update a running task. Cancel it first.");
|
|
|
|
if (title is not null) task.Title = title;
|
|
if (description is not null) task.Description = description;
|
|
if (commitType is not null) task.CommitType = commitType;
|
|
await _tasks.UpdateAsync(task, cancellationToken);
|
|
|
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return ToDto(reload);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Append a subtask (step) to a task. orderNum defaults to the end. " +
|
|
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")]
|
|
public async Task<TaskDto> AddSubtask(
|
|
string taskId,
|
|
string title,
|
|
int? orderNum,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(title))
|
|
throw new InvalidOperationException("title is required.");
|
|
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
|
var tasks = new TaskRepository(ctx);
|
|
var subtasks = new SubtaskRepository(ctx);
|
|
|
|
var task = await tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
if (task.Status == TaskStatus.Running)
|
|
throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");
|
|
|
|
var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
|
|
var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);
|
|
|
|
await subtasks.AddAsync(new SubtaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
TaskId = taskId,
|
|
Title = title.Trim(),
|
|
Completed = false,
|
|
OrderNum = order,
|
|
CreatedAt = DateTime.UtcNow,
|
|
}, cancellationToken);
|
|
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return ToDto(task);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " +
|
|
"use run_task_now or cancel_task for execution control, and review_task to act on a WaitingForReview task. " +
|
|
"Settable: Idle (reset to editable), Queued (enqueue for execution). " +
|
|
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")]
|
|
public async Task<TaskDto> UpdateTaskStatus(
|
|
string taskId,
|
|
string status,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var target))
|
|
throw new InvalidOperationException(
|
|
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, Done, Failed, Cancelled.");
|
|
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
switch (target)
|
|
{
|
|
case TaskStatus.Idle:
|
|
await _tasks.ResetToManualAsync(taskId, cancellationToken);
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
break;
|
|
|
|
case TaskStatus.Queued:
|
|
var enqueueResult = await _state.EnqueueAsync(taskId, cancellationToken);
|
|
if (!enqueueResult.Ok)
|
|
throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task.");
|
|
break;
|
|
|
|
default:
|
|
throw new InvalidOperationException(
|
|
$"Status '{target}' is not settable externally. Use run_task_now or cancel_task.");
|
|
}
|
|
|
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
|
return ToDto(reload);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Review a task that is WaitingForReview. " +
|
|
"decision='approve' → Done. " +
|
|
"decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " +
|
|
"decision='reject_park' → Idle for manual editing (feedback ignored). " +
|
|
"decision='cancel' → Cancelled. " +
|
|
"Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued).")]
|
|
public async Task<TaskDto> ReviewTask(
|
|
string taskId,
|
|
string decision,
|
|
string? feedback,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
TransitionResult result = decision.Trim().ToLowerInvariant() switch
|
|
{
|
|
"approve" => await _state.ApproveReviewAsync(taskId, cancellationToken),
|
|
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
|
|
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
|
|
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
|
|
_ => throw new InvalidOperationException(
|
|
$"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
|
|
};
|
|
|
|
if (!result.Ok)
|
|
throw new InvalidOperationException(result.Reason ?? "Review action failed.");
|
|
|
|
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
|
|
}
|
|
|
|
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")]
|
|
public async Task RunTaskNow(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await _queue.RunNow(taskId);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
throw new InvalidOperationException("Override slot busy. Try again later.");
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
throw new InvalidOperationException($"Task {taskId} not found.");
|
|
}
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
}
|
|
|
|
[McpServerTool, Description("Cancel a running task. Returns { cancelled: true, id } if the task was running and cancellation was requested; cancelled is false if the task was not running.")]
|
|
public async Task<CancelTaskResult> CancelTask(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
var cancelled = _queue.CancelTask(taskId);
|
|
if (cancelled) await _broadcaster.TaskUpdated(taskId);
|
|
return new CancelTaskResult(cancelled, taskId);
|
|
}
|
|
|
|
[McpServerTool, Description("Delete a task. Returns { deleted: true, id } on success. Throws if the task is not found or is currently Running — cancel it first.")]
|
|
public async Task<DeleteTaskResult> DeleteTask(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
if (task.Status == TaskStatus.Running)
|
|
throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");
|
|
|
|
await _tasks.DeleteAsync(taskId, cancellationToken);
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return new DeleteTaskResult(true, taskId);
|
|
}
|
|
|
|
// ── Status reference ─────────────────────────────────────────────────────
|
|
|
|
[McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")]
|
|
public Task<IReadOnlyList<StatusValueDto>> GetTaskStatusValues() =>
|
|
Task.FromResult<IReadOnlyList<StatusValueDto>>([
|
|
new("Idle", "Not yet queued; task is editable and will not run until enqueued."),
|
|
new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."),
|
|
new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."),
|
|
new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."),
|
|
new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
|
|
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
|
|
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
|
|
]);
|
|
|
|
// ── Worktree / git tools ──────────────────────────────────────────────────
|
|
|
|
[McpServerTool, Description(
|
|
"Get git worktree details for a task: path, branch, headCommit (current HEAD SHA), " +
|
|
"baseCommit (SHA where the branch was created), ahead (commits on branch since base), " +
|
|
"behind (commits on main not yet on this branch; 0 if 'main' ref is unreachable), " +
|
|
"isDirty (has uncommitted changes in the worktree directory). " +
|
|
"Throws if the task or its worktree does not exist.")]
|
|
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
|
|
{
|
|
var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);
|
|
|
|
var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit)
|
|
? wt.HeadCommit
|
|
: await TryRunGitAsync(wt.Path, ["rev-parse", "HEAD"], cancellationToken) ?? wt.BaseCommit;
|
|
|
|
var isDirty = Directory.Exists(wt.Path) && await _git.HasChangesAsync(wt.Path, cancellationToken);
|
|
var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken);
|
|
var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken);
|
|
|
|
return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Get the diff for a task's worktree relative to its base commit. " +
|
|
"stat=false (default): returns the full unified diff, capped at 200 KB (truncated=true when larger). " +
|
|
"stat=true: returns a --stat summary (changed files with insertion/deletion counts). " +
|
|
"files always lists the changed file paths regardless of stat mode. " +
|
|
"totalBytes is the uncapped diff size (useful when truncated=true). " +
|
|
"Throws if the task has no worktree or the worktree directory is missing from disk.")]
|
|
public async Task<TaskDiffDto> GetTaskDiff(
|
|
string taskId, bool stat = false, CancellationToken cancellationToken = default)
|
|
{
|
|
var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);
|
|
|
|
if (!Directory.Exists(wt.Path))
|
|
throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}");
|
|
|
|
const int maxBytes = 200 * 1024;
|
|
|
|
if (stat)
|
|
{
|
|
var diffStat = await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", cancellationToken);
|
|
return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length);
|
|
}
|
|
|
|
var diff = await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, cancellationToken);
|
|
var files = ParseDiffFileNames(diff);
|
|
|
|
if (diff.Length <= maxBytes)
|
|
return new TaskDiffDto(diff, files, false, diff.Length);
|
|
|
|
return new TaskDiffDto(diff[..maxBytes], files, true, diff.Length);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Merge a task's worktree branch into targetBranch (default: main). " +
|
|
"noFf=true (default): always creates a merge commit (--no-ff). " +
|
|
"dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
|
|
"Refuses if task status is not Done (status values: Idle, Queued, Running, Done, Failed, Cancelled). " +
|
|
"On success: merged=true, mergeCommit contains the new merge commit SHA. " +
|
|
"On conflict: the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files.")]
|
|
public async Task<MergeTaskResultDto> MergeTask(
|
|
string taskId,
|
|
string targetBranch = "main",
|
|
bool noFf = true,
|
|
bool dryRun = false,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
if (task.Status != TaskStatus.Done)
|
|
throw new InvalidOperationException(
|
|
$"Task must be Done to merge (current status: {task.Status}). " +
|
|
"Valid statuses for merge: Done.");
|
|
|
|
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
|
|
|
if (dryRun)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
|
if (wt.State != WorktreeState.Active)
|
|
throw new InvalidOperationException(
|
|
$"Worktree state must be Active to merge (current: {wt.State}).");
|
|
return new MergeTaskResultDto(false, null, []);
|
|
}
|
|
|
|
var commitMessage = $"Merge task branch for: {task.Title}";
|
|
var result = await _merge.MergeAsync(
|
|
taskId, targetBranch, removeWorktree: false, commitMessage, cancellationToken);
|
|
|
|
if (result.Status == TaskMergeService.StatusMerged)
|
|
{
|
|
string? mergeCommit = null;
|
|
try
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(list?.WorkingDir) && Directory.Exists(list.WorkingDir))
|
|
mergeCommit = await _git.RevParseHeadAsync(list.WorkingDir, cancellationToken);
|
|
}
|
|
catch { /* mergeCommit is optional */ }
|
|
return new MergeTaskResultDto(true, mergeCommit, []);
|
|
}
|
|
|
|
if (result.Status == TaskMergeService.StatusConflict)
|
|
return new MergeTaskResultDto(false, null, result.ConflictFiles);
|
|
|
|
throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"List all ClaudeDo-tracked worktrees. " +
|
|
"Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " +
|
|
"isDirty (has uncommitted changes), mergedIntoMain (worktree state is Merged). " +
|
|
"Only worktrees recorded in the ClaudeDo database are returned.")]
|
|
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(CancellationToken cancellationToken)
|
|
{
|
|
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
|
|
var results = await Task.WhenAll(rows.Select(async row =>
|
|
{
|
|
var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken);
|
|
var headCommit = row.PathExistsOnDisk
|
|
? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
|
|
: "";
|
|
return new WorktreeListItemDto(
|
|
row.TaskId, row.Path, row.BranchName, headCommit,
|
|
isDirty, row.State == WorktreeState.Merged);
|
|
}));
|
|
return results;
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Remove a task's worktree directory and delete its git branch. " +
|
|
"force=false (default): refuses if the worktree has uncommitted changes or the task is Running. " +
|
|
"force=true: removes even a dirty worktree (uncommitted changes are lost); task must not be Running. " +
|
|
"Returns removed=true on success; branchDeleted reflects whether the branch was also removed.")]
|
|
public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
|
|
string taskId, bool force = false, CancellationToken cancellationToken = default)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
|
|
|
if (task.Status == TaskStatus.Running)
|
|
throw new InvalidOperationException("Cannot remove worktree of a running task.");
|
|
|
|
if (!force && Directory.Exists(wt.Path))
|
|
{
|
|
var isDirty = await _git.HasChangesAsync(wt.Path, cancellationToken);
|
|
if (isDirty)
|
|
throw new InvalidOperationException(
|
|
"Worktree has uncommitted changes. Use force=true to remove anyway (changes will be lost).");
|
|
}
|
|
|
|
var path = wt.Path;
|
|
var result = await _maintenance.ForceRemoveAsync(taskId, cancellationToken);
|
|
return new CleanupWorktreeResult(result.Removed, path, result.Removed);
|
|
}
|
|
|
|
// ── Daily prep ───────────────────────────────────────────────────────────
|
|
|
|
[McpServerTool, Description(
|
|
"Daily prep: returns the open tasks eligible for today's MyDay selection. " +
|
|
"candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " +
|
|
"currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " +
|
|
"maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")]
|
|
public async Task<DailyPrepDataDto> GetDailyPrepCandidates(CancellationToken cancellationToken)
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
|
|
var excludes = DailyPrepFilter.ParseExcludes(settings.ReportExcludedPaths);
|
|
var maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
|
|
|
|
var idle = await ctx.Tasks
|
|
.AsNoTracking()
|
|
.Include(t => t.List)
|
|
.Where(t => t.Status == TaskStatus.Idle)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var currentMyDay = idle
|
|
.Where(t => t.IsMyDay)
|
|
.OrderBy(t => t.SortOrder)
|
|
.Select(ToCandidate)
|
|
.ToList();
|
|
|
|
var candidates = idle
|
|
.Where(t => !t.IsMyDay
|
|
&& t.BlockedByTaskId == null
|
|
&& DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
|
|
.OrderBy(t => t.CreatedAt)
|
|
.Select(ToCandidate)
|
|
.ToList();
|
|
|
|
return new DailyPrepDataDto(maxTasks, candidates, currentMyDay);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " +
|
|
"(use consecutive sortOrder values to keep related tasks together). " +
|
|
"Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " +
|
|
"clearing (isMyDay=false) is always allowed.")]
|
|
public async Task<TaskDto> SetMyDay(
|
|
string taskId,
|
|
bool isMyDay,
|
|
int? sortOrder,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
if (isMyDay && !task.IsMyDay)
|
|
{
|
|
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
|
|
var max = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
|
|
var openMyDay = await ctx.Tasks.CountAsync(
|
|
t => t.IsMyDay && t.Status == TaskStatus.Idle, cancellationToken);
|
|
if (openMyDay >= max)
|
|
throw new InvalidOperationException(
|
|
$"MyDay limit {max} reached. Clear a task before adding another.");
|
|
}
|
|
|
|
task.IsMyDay = isMyDay;
|
|
if (sortOrder is not null) task.SortOrder = sortOrder.Value;
|
|
await ctx.SaveChangesAsync(cancellationToken);
|
|
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return ToDto(task);
|
|
}
|
|
|
|
private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new(
|
|
t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description,
|
|
t.IsStarred, t.ScheduledFor, t.CreatedAt);
|
|
|
|
// ── Private helpers ───────────────────────────────────────────────────────
|
|
|
|
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync(
|
|
string taskId, CancellationToken ct)
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
|
|
?? throw new InvalidOperationException("List not found.");
|
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct)
|
|
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
|
return (task, list, wt);
|
|
}
|
|
|
|
private async Task<bool> TryGetIsDirtyAsync(string path, CancellationToken ct)
|
|
{
|
|
try { return await _git.HasChangesAsync(path, ct); }
|
|
catch { return false; }
|
|
}
|
|
|
|
// Minimal git runner for operations not covered by GitService (rev-list --count, rev-parse from worktree).
|
|
private static async Task<string?> TryRunGitAsync(string dir, string[] args, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var psi = new ProcessStartInfo("git")
|
|
{
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
CreateNoWindow = true,
|
|
};
|
|
psi.ArgumentList.Add("-C");
|
|
psi.ArgumentList.Add(dir);
|
|
foreach (var a in args) psi.ArgumentList.Add(a);
|
|
using var proc = Process.Start(psi)!;
|
|
await using var _ = ct.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { } });
|
|
var stdout = await proc.StandardOutput.ReadToEndAsync();
|
|
await proc.WaitForExitAsync(CancellationToken.None);
|
|
ct.ThrowIfCancellationRequested();
|
|
return proc.ExitCode == 0 ? stdout.Trim() : null;
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch { return null; }
|
|
}
|
|
|
|
private static async Task<int> GitRevListCountAsync(string dir, string range, CancellationToken ct)
|
|
{
|
|
var result = await TryRunGitAsync(dir, ["rev-list", "--count", range], ct);
|
|
return int.TryParse(result, out var n) ? n : 0;
|
|
}
|
|
|
|
private static IReadOnlyList<string> ParseDiffFileNames(string diff)
|
|
{
|
|
var files = new List<string>();
|
|
foreach (var line in diff.Split('\n'))
|
|
{
|
|
var s = line.TrimEnd('\r');
|
|
if (s.StartsWith("+++ b/", StringComparison.Ordinal))
|
|
files.Add(s[6..]);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
private static IReadOnlyList<string> ParseDiffStatFileNames(string stat)
|
|
{
|
|
var files = new List<string>();
|
|
foreach (var line in stat.Split('\n'))
|
|
{
|
|
var idx = line.IndexOf('|');
|
|
if (idx > 0) files.Add(line[..idx].Trim());
|
|
}
|
|
return files;
|
|
}
|
|
|
|
private static TaskDto ToDto(TaskEntity t) => new(
|
|
t.Id,
|
|
t.ListId,
|
|
t.Title,
|
|
t.Description,
|
|
t.Status.ToString(),
|
|
t.Result,
|
|
t.CreatedBy,
|
|
t.CreatedAt,
|
|
t.StartedAt,
|
|
t.FinishedAt,
|
|
t.IsMyDay,
|
|
t.SortOrder);
|
|
}
|
|
|
|
internal static class DailyPrepFilter
|
|
{
|
|
public static string[] ParseExcludes(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json)) return [];
|
|
try
|
|
{
|
|
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
|
|
return list is null ? [] : list.Select(Normalize).Where(p => p.Length > 0).ToArray();
|
|
}
|
|
catch (System.Text.Json.JsonException) { return []; }
|
|
}
|
|
|
|
public static bool IsIncludedRepo(string? workingDir, string[] excludes)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(workingDir)) return false;
|
|
var norm = Normalize(workingDir);
|
|
return !excludes.Any(p => norm.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static string Normalize(string path) =>
|
|
path.Trim().Replace('/', '\\').TrimEnd('\\');
|
|
}
|