You've already forked agentic-coding-workflow
250 lines
8.3 KiB
Go
250 lines
8.3 KiB
Go
package workspace
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
|
|
)
|
|
|
|
// IndexBuildHook is a best-effort callback fired after a worktree is created (or
|
|
// a workspace is synced) to enqueue a code-index build. Injected from app.go via
|
|
// SetIndexBuildHook to avoid a workspace→codeindex import cycle. May be nil.
|
|
type IndexBuildHook func(ctx context.Context, wsID uuid.UUID, worktreeID *uuid.UUID, commitSHA string) error
|
|
|
|
type worktreeService struct {
|
|
repo Repository
|
|
rec audit.Recorder
|
|
gitr git.Runner
|
|
pa ProjectAccess
|
|
dataDir string
|
|
indexHook IndexBuildHook
|
|
}
|
|
|
|
// NewWorktreeService 构造 WorktreeService。
|
|
func NewWorktreeService(repo Repository, rec audit.Recorder, gitr git.Runner, pa ProjectAccess, dataDir string) WorktreeService {
|
|
return &worktreeService{repo: repo, rec: rec, gitr: gitr, pa: pa, dataDir: dataDir}
|
|
}
|
|
|
|
// SetIndexBuildHook injects the code-index enqueue callback (app wiring). Safe to
|
|
// call once at construction; nil disables index builds on worktree create.
|
|
func (s *worktreeService) SetIndexBuildHook(h IndexBuildHook) { s.indexHook = h }
|
|
|
|
func (s *worktreeService) Create(ctx context.Context, c Caller, wsID uuid.UUID, in CreateWorktreeInput) (*Worktree, error) {
|
|
ws, err := s.repo.GetWorkspaceByID(ctx, wsID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID); err != nil {
|
|
return nil, err
|
|
} else if !canWrite {
|
|
return nil, errs.New(errs.CodeForbidden, "no write access")
|
|
}
|
|
if err := ValidateBranch(in.Branch); err != nil {
|
|
return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid branch")
|
|
}
|
|
base := in.BaseBranch
|
|
if base == "" {
|
|
base = ws.DefaultBranch
|
|
}
|
|
if err := ValidateBranch(base); err != nil {
|
|
return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid base branch")
|
|
}
|
|
wtPath := WorktreePath(s.dataDir, wsID, in.Branch)
|
|
if err := s.gitr.WorktreeAdd(ctx, ws.MainPath, in.Branch, wtPath, "origin/"+base); err != nil {
|
|
if git.IsConflict(err) {
|
|
return nil, errs.Wrap(err, errs.CodeWorktreeBranchConflict, "branch already used")
|
|
}
|
|
return nil, errs.Wrap(err, errs.CodeGitCmdFailed, "git worktree add")
|
|
}
|
|
wt := &Worktree{
|
|
ID: uuid.New(), WorkspaceID: wsID, Branch: in.Branch, Path: wtPath,
|
|
Status: WorktreeStatusIdle,
|
|
}
|
|
var saved *Worktree
|
|
err = s.repo.InTx(ctx, func(tx Tx) error {
|
|
w, e := tx.InsertWorktree(ctx, wt)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
saved = w
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
// 回滚磁盘
|
|
_ = s.gitr.WorktreeRemove(ctx, ws.MainPath, wtPath)
|
|
return nil, err
|
|
}
|
|
s.audit(ctx, &c.UserID, "worktree.create", "worktree", saved.ID.String(), map[string]any{"branch": in.Branch})
|
|
// Best-effort: enqueue a code-index build for the new worktree at its HEAD.
|
|
// Failures are logged via audit but never block worktree creation.
|
|
if s.indexHook != nil {
|
|
wtID := saved.ID
|
|
if herr := s.indexHook(ctx, wsID, &wtID, ""); herr != nil {
|
|
s.audit(ctx, &c.UserID, "worktree.index_enqueue_failed", "worktree", saved.ID.String(), map[string]any{"err": herr.Error()})
|
|
}
|
|
}
|
|
return saved, nil
|
|
}
|
|
|
|
func (s *worktreeService) List(ctx context.Context, c Caller, wsID uuid.UUID) ([]*Worktree, error) {
|
|
ws, err := s.repo.GetWorkspaceByID(ctx, wsID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if canRead, _, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID); err != nil {
|
|
return nil, err
|
|
} else if !canRead {
|
|
return nil, errs.New(errs.CodeForbidden, "no read access")
|
|
}
|
|
return s.repo.ListWorktreesByWorkspace(ctx, wsID)
|
|
}
|
|
|
|
func (s *worktreeService) Delete(ctx context.Context, c Caller, wtID uuid.UUID) error {
|
|
ws, err := s.authorizeWorktreeWrite(ctx, c, wtID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// check-and-transition 必须原子:在 tx 内对行加 FOR UPDATE,确认 idle 后才转 pruning。
|
|
// 否则并发 Acquire(FOR UPDATE)可能刚把它置为 active,而这里用非锁读看到旧的 idle,
|
|
// 进而 git rm 掉 agent 正在使用的目录。仅 idle→pruning 在锁下完成。
|
|
var wtPath string
|
|
err = s.repo.InTx(ctx, func(tx Tx) error {
|
|
wt, e := tx.GetWorktreeByIDForUpdate(ctx, wtID)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if wt.Status != WorktreeStatusIdle {
|
|
return errs.New(errs.CodeWorktreeAlreadyActive, "release before delete")
|
|
}
|
|
pruned, e := tx.SetWorktreePruning(ctx, wtID)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
wtPath = pruned.Path
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// 仅在守卫后的转移成功后才动磁盘。中途失败 row 留 pruning。
|
|
if rmErr := s.gitr.WorktreeRemove(ctx, ws.MainPath, wtPath); rmErr != nil {
|
|
return errs.Wrap(rmErr, errs.CodeGitCmdFailed, "git worktree remove")
|
|
}
|
|
if err := s.repo.DeleteWorktree(ctx, wtID); err != nil {
|
|
return err
|
|
}
|
|
s.audit(ctx, &c.UserID, "worktree.delete", "worktree", wtID.String(), nil)
|
|
return nil
|
|
}
|
|
|
|
func (s *worktreeService) Acquire(ctx context.Context, c Caller, wtID uuid.UUID) (*Worktree, error) {
|
|
if _, err := s.authorizeWorktreeWrite(ctx, c, wtID); err != nil {
|
|
return nil, err
|
|
}
|
|
holder := c.Holder()
|
|
var out *Worktree
|
|
err := s.repo.InTx(ctx, func(tx Tx) error {
|
|
wt, err := tx.GetWorktreeByIDForUpdate(ctx, wtID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch wt.Status {
|
|
case WorktreeStatusPruning:
|
|
return errs.New(errs.CodeNotFound, "worktree being pruned")
|
|
case WorktreeStatusActive:
|
|
if wt.ActiveHolder == holder {
|
|
out = wt // idempotent
|
|
return nil
|
|
}
|
|
return errs.New(errs.CodeWorktreeAlreadyActive, "held by another holder")
|
|
case WorktreeStatusIdle:
|
|
updated, err := tx.SetWorktreeActive(ctx, wtID, holder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out = updated
|
|
return nil
|
|
}
|
|
return errs.New(errs.CodeInternal, "unknown worktree status")
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.audit(ctx, &c.UserID, "worktree.acquire", "worktree", wtID.String(), map[string]any{"holder": holder})
|
|
return out, nil
|
|
}
|
|
|
|
func (s *worktreeService) Release(ctx context.Context, c Caller, wtID uuid.UUID) (*Worktree, error) {
|
|
if _, err := s.authorizeWorktreeWrite(ctx, c, wtID); err != nil {
|
|
return nil, err
|
|
}
|
|
holder := c.Holder()
|
|
var out *Worktree
|
|
err := s.repo.InTx(ctx, func(tx Tx) error {
|
|
wt, err := tx.GetWorktreeByIDForUpdate(ctx, wtID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if wt.Status != WorktreeStatusActive {
|
|
return errs.New(errs.CodeWorktreeNotHeldByCaller, "worktree not active")
|
|
}
|
|
if wt.ActiveHolder != holder && !c.IsAdmin {
|
|
return errs.New(errs.CodeWorktreeNotHeldByCaller, "not held by caller")
|
|
}
|
|
updated, err := tx.SetWorktreeIdle(ctx, wtID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out = updated
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.audit(ctx, &c.UserID, "worktree.release", "worktree", wtID.String(), map[string]any{"holder": holder})
|
|
return out, nil
|
|
}
|
|
|
|
// ReleaseByHolder 释放给定 holder 字符串持有的所有活跃 worktree。
|
|
// 实现仅委托 repo;audit 由 caller 在更高层根据自己上下文写入。
|
|
func (s *worktreeService) ReleaseByHolder(ctx context.Context, holder string) ([]*Worktree, error) {
|
|
return s.repo.ReleaseWorktreesByHolder(ctx, holder)
|
|
}
|
|
|
|
// authorizeWorktreeWrite 把 worktree → workspace → project 解析出来并要求 write 权限,
|
|
// 与 Delete/Create 的鉴权方式一致。返回 worktree 所属的 workspace 供调用方使用
|
|
// (如取 MainPath)。Acquire/Release 此前缺失该校验,导致任意已登录用户可凭 UUID
|
|
// 跨租户锁定/释放任意 worktree。
|
|
func (s *worktreeService) authorizeWorktreeWrite(ctx context.Context, c Caller, wtID uuid.UUID) (*Workspace, error) {
|
|
wt, err := s.repo.GetWorktreeByID(ctx, wtID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ws, err := s.repo.GetWorkspaceByID(ctx, wt.WorkspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID); err != nil {
|
|
return nil, err
|
|
} else if !canWrite {
|
|
return nil, errs.New(errs.CodeForbidden, "no write access")
|
|
}
|
|
return ws, nil
|
|
}
|
|
|
|
func (s *worktreeService) audit(ctx context.Context, uid *uuid.UUID, action, ttype, tid string, meta map[string]any) {
|
|
_ = s.rec.Record(ctx, audit.Entry{
|
|
UserID: uid, Action: action, TargetType: ttype, TargetID: tid, Metadata: meta,
|
|
})
|
|
}
|
|
|
|
// 防止未使用 import lint 移除
|
|
var _ = errors.Is
|
|
var _ = time.Now
|