feat(worker): scaffold TaskMergeService with pre-flight checks
This commit is contained in:
104
src/ClaudeDo.Worker/Services/TaskMergeService.cs
Normal file
104
src/ClaudeDo.Worker/Services/TaskMergeService.cs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Services;
|
||||||
|
|
||||||
|
public sealed record MergeResult(
|
||||||
|
string Status,
|
||||||
|
IReadOnlyList<string> ConflictFiles,
|
||||||
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
public sealed record MergeTargets(
|
||||||
|
string DefaultBranch,
|
||||||
|
IReadOnlyList<string> LocalBranches);
|
||||||
|
|
||||||
|
public sealed class TaskMergeService
|
||||||
|
{
|
||||||
|
public const string StatusMerged = "merged";
|
||||||
|
public const string StatusConflict = "conflict";
|
||||||
|
public const string StatusBlocked = "blocked";
|
||||||
|
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly GitService _git;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
private readonly ILogger<TaskMergeService> _logger;
|
||||||
|
|
||||||
|
public TaskMergeService(
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
GitService git,
|
||||||
|
HubBroadcaster broadcaster,
|
||||||
|
ILogger<TaskMergeService> logger)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_git = git;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MergeResult> MergeAsync(
|
||||||
|
string taskId,
|
||||||
|
string targetBranch,
|
||||||
|
bool removeWorktree,
|
||||||
|
string commitMessage,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
TaskEntity task;
|
||||||
|
ListEntity list;
|
||||||
|
WorktreeEntity? wt;
|
||||||
|
|
||||||
|
using (var ctx = _dbFactory.CreateDbContext())
|
||||||
|
{
|
||||||
|
task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
|
||||||
|
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||||
|
list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
|
||||||
|
?? throw new InvalidOperationException("List not found.");
|
||||||
|
wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.Status == TaskStatus.Running)
|
||||||
|
return Blocked("task is running");
|
||||||
|
if (wt is null)
|
||||||
|
return Blocked("task has no worktree");
|
||||||
|
if (wt.State != WorktreeState.Active)
|
||||||
|
return Blocked($"worktree state is {wt.State}");
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
|
return Blocked("list has no working directory");
|
||||||
|
if (!await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
||||||
|
return Blocked("working directory is not a git repository");
|
||||||
|
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
|
||||||
|
return Blocked("target working directory is mid-merge");
|
||||||
|
if (await _git.HasChangesAsync(list.WorkingDir, ct))
|
||||||
|
return Blocked("target working tree has uncommitted changes");
|
||||||
|
|
||||||
|
// Body added in later tasks.
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
TaskEntity task;
|
||||||
|
ListEntity list;
|
||||||
|
using (var ctx = _dbFactory.CreateDbContext())
|
||||||
|
{
|
||||||
|
task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
|
||||||
|
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||||
|
list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
|
||||||
|
?? throw new InvalidOperationException("List not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
|
return new MergeTargets("", Array.Empty<string>());
|
||||||
|
|
||||||
|
var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
|
||||||
|
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
|
||||||
|
return new MergeTargets(current, branches);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MergeResult Blocked(string reason) =>
|
||||||
|
new(StatusBlocked, Array.Empty<string>(), reason);
|
||||||
|
}
|
||||||
334
tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
Normal file
334
tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
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 class TaskMergeServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<DbFixture> _dbs = new();
|
||||||
|
private readonly List<GitRepoFixture> _repos = new();
|
||||||
|
private readonly List<(string repoDir, string wtPath)> _wtCleanups = new();
|
||||||
|
|
||||||
|
private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; }
|
||||||
|
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var (repoDir, wtPath) in _wtCleanups)
|
||||||
|
{
|
||||||
|
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
|
||||||
|
}
|
||||||
|
foreach (var d in _dbs) try { d.Dispose(); } catch { }
|
||||||
|
foreach (var r in _repos) try { r.Dispose(); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(DbFixture db)
|
||||||
|
{
|
||||||
|
var fakeHub = new MergeRecordingHubContext();
|
||||||
|
var broadcaster = new HubBroadcaster(fakeHub);
|
||||||
|
var svc = new TaskMergeService(
|
||||||
|
db.CreateFactory(),
|
||||||
|
new GitService(),
|
||||||
|
broadcaster,
|
||||||
|
NullLogger<TaskMergeService>.Instance);
|
||||||
|
return (svc, fakeHub.Proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorktreeManager BuildWorktreeManager(DbFixture db)
|
||||||
|
{
|
||||||
|
return new WorktreeManager(
|
||||||
|
new GitService(),
|
||||||
|
db.CreateFactory(),
|
||||||
|
new ClaudeDo.Worker.Config.WorkerConfig { WorktreeRootStrategy = "sibling" },
|
||||||
|
NullLogger<WorktreeManager>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(ListEntity list, TaskEntity task)> SeedListAndTask(
|
||||||
|
DbFixture db, string workingDir, TaskStatus status)
|
||||||
|
{
|
||||||
|
var list = new ListEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
Name = "merge-test",
|
||||||
|
WorkingDir = workingDir,
|
||||||
|
DefaultCommitType = "feat",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var task = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = list.Id,
|
||||||
|
Title = "merge-task",
|
||||||
|
Status = status,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
await new ListRepository(ctx).AddAsync(list);
|
||||||
|
await new TaskRepository(ctx).AddAsync(task);
|
||||||
|
return (list, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeAsync_RunningTask_ReturnsBlocked()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Running);
|
||||||
|
var (svc, proxy) = BuildService(db);
|
||||||
|
|
||||||
|
var result = await svc.MergeAsync(task.Id, "main", false, "msg", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("blocked", result.Status);
|
||||||
|
Assert.Contains("running", result.ErrorMessage ?? "");
|
||||||
|
Assert.Empty(proxy.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeAsync_NoWorktree_ReturnsBlocked()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.Done);
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
|
||||||
|
var result = await svc.MergeAsync(task.Id, "main", false, "msg", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("blocked", result.Status);
|
||||||
|
Assert.Contains("no worktree", result.ErrorMessage ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeAsync_FfAble_KeepWorktree_SetsMergedAndBroadcasts()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||||
|
|
||||||
|
// Create worktree and make a real commit inside it.
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
var (svc, proxy) = BuildService(db);
|
||||||
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
||||||
|
commitMessage: "Merge task", ct: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("merged", result.Status);
|
||||||
|
Assert.Empty(result.ConflictFiles);
|
||||||
|
|
||||||
|
// Worktree state now Merged, dir and branch still present.
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||||
|
Assert.NotNull(wt);
|
||||||
|
Assert.Equal(WorktreeState.Merged, wt!.State);
|
||||||
|
Assert.True(Directory.Exists(wtCtx.WorktreePath));
|
||||||
|
|
||||||
|
// Broadcast fired.
|
||||||
|
Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id);
|
||||||
|
|
||||||
|
// added.txt is now on the main branch of the repo.
|
||||||
|
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeAsync_FfAble_RemoveWorktree_CleansEverything()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||||
|
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feature.txt"), "x\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
|
||||||
|
commitMessage: "Merge", ct: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("merged", result.Status);
|
||||||
|
Assert.False(Directory.Exists(wtCtx.WorktreePath));
|
||||||
|
|
||||||
|
// Branch must be gone.
|
||||||
|
var branches = await new GitService().ListLocalBranchesAsync(repo.RepoDir);
|
||||||
|
Assert.DoesNotContain(wtCtx.BranchName, branches);
|
||||||
|
|
||||||
|
// DB state still Merged.
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||||
|
Assert.Equal(WorktreeState.Merged, wt!.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeAsync_DivergedNonConflicting_ProducesMergeCommit()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||||
|
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "feature.txt"), "feat\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
// Advance main by adding a different file.
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "main-only.txt"), "main\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main moved");
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
|
||||||
|
commitMessage: "Merge diverged", ct: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("merged", result.Status);
|
||||||
|
// HEAD must be a merge commit (two parents).
|
||||||
|
var parents = GitRepoFixture.RunGit(repo.RepoDir, "rev-list", "--parents", "-n", "1", "HEAD").Trim();
|
||||||
|
Assert.True(parents.Split(' ').Length >= 3, $"Expected merge commit, got '{parents}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeAsync_Conflict_AbortsAndReturnsConflictedFiles()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||||
|
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
|
||||||
|
// Worktree edits README.md
|
||||||
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
// Main also edits README.md (conflicting).
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "chore: main edit");
|
||||||
|
var mainHeadBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||||
|
|
||||||
|
var (svc, proxy) = BuildService(db);
|
||||||
|
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: true,
|
||||||
|
commitMessage: "Merge", ct: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("conflict", result.Status);
|
||||||
|
Assert.Contains("README.md", result.ConflictFiles);
|
||||||
|
|
||||||
|
// Main branch must be restored exactly.
|
||||||
|
var mainHeadAfter = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||||
|
Assert.Equal(mainHeadBefore, mainHeadAfter);
|
||||||
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
||||||
|
|
||||||
|
// Worktree state stays Active (no broadcast).
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||||
|
Assert.Equal(WorktreeState.Active, wt!.State);
|
||||||
|
Assert.DoesNotContain(proxy.Calls, c => c.Method == "WorktreeUpdated");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTargetsAsync_ReturnsCurrentAndLocalBranches()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "feature/extra");
|
||||||
|
var db = NewDb();
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(targets.DefaultBranch));
|
||||||
|
Assert.Contains("feature/extra", targets.LocalBranches);
|
||||||
|
Assert.Contains(targets.DefaultBranch, targets.LocalBranches);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeAsync_DirtyWorkingTree_ReturnsBlocked()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||||
|
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
|
||||||
|
// Dirty the target working dir.
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "dirt.txt"), "dirty\n");
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var result = await svc.MergeAsync(task.Id, "main", false, "Merge", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("blocked", result.Status);
|
||||||
|
Assert.Contains("uncommitted", result.ErrorMessage ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Test doubles
|
||||||
|
|
||||||
|
internal sealed record MergeHubCall(string Method, object?[] Args);
|
||||||
|
|
||||||
|
internal sealed class MergeRecordingClientProxy : IClientProxy
|
||||||
|
{
|
||||||
|
public readonly List<MergeHubCall> Calls = new();
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Calls.Add(new MergeHubCall(method, args));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class MergeRecordingHubClients : IHubClients
|
||||||
|
{
|
||||||
|
public MergeRecordingClientProxy AllProxy { get; } = new();
|
||||||
|
public IClientProxy All => AllProxy;
|
||||||
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => AllProxy;
|
||||||
|
public IClientProxy Client(string connectionId) => AllProxy;
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => AllProxy;
|
||||||
|
public IClientProxy Group(string groupName) => AllProxy;
|
||||||
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => AllProxy;
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => AllProxy;
|
||||||
|
public IClientProxy User(string userId) => AllProxy;
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class MergeRecordingHubContext : IHubContext<ClaudeDo.Worker.Hub.WorkerHub>
|
||||||
|
{
|
||||||
|
private readonly MergeRecordingHubClients _clients = new();
|
||||||
|
public MergeRecordingClientProxy Proxy => _clients.AllProxy;
|
||||||
|
public IHubClients Clients => _clients;
|
||||||
|
public IGroupManager Groups => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
Reference in New Issue
Block a user