// workspace_service.go 实现 WorkspaceService。Service 不直接调 git CLI; // 写路径上调 cloneRunner.Run(异步)/ Runner.Fetch(同步)。 // 鉴权:private project 写要求 owner+admin;internal project 写要求 owner+admin; // 读权限委托给 ProjectAccess(窄接口,避免对 internal/project 的强依赖)。 package workspace import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "os" "path/filepath" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/infra/git" ) // ProjectAccess 是一个窄接口,让 workspace.Service 不依赖 internal/project 全部。 type ProjectAccess interface { Resolve(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectSlug string) (uuid.UUID, bool, bool, error) ResolveByID(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, bool, error) } // CloneScheduler 抽象 clone 的异步调度。 type CloneScheduler interface { Schedule(wsID uuid.UUID) } type workspaceService struct { repo Repository rec audit.Recorder enc *crypto.Encryptor gitr git.Runner pa ProjectAccess clones CloneScheduler dataDir string fetchTimout time.Duration } // NewWorkspaceService 构造 WorkspaceService 实现。 func NewWorkspaceService( repo Repository, rec audit.Recorder, enc *crypto.Encryptor, gitr git.Runner, pa ProjectAccess, clones CloneScheduler, dataDir string, ) WorkspaceService { return &workspaceService{ repo: repo, rec: rec, enc: enc, gitr: gitr, pa: pa, clones: clones, dataDir: dataDir, fetchTimout: 5 * time.Minute, } } func (s *workspaceService) Create(ctx context.Context, c Caller, projectSlug string, in CreateWorkspaceInput) (*Workspace, error) { pid, _, canWrite, err := s.pa.Resolve(ctx, c.UserID, c.IsAdmin, projectSlug) if err != nil { return nil, err } if !canWrite { return nil, errs.New(errs.CodeForbidden, "no write access to project") } if err := ValidateSlug(in.Slug); err != nil { return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid slug") } if err := ValidateRemoteURL(in.GitRemoteURL); err != nil { return nil, errs.Wrap(err, errs.CodeWorkspaceRemoteInvalid, "invalid remote url") } if !in.Credential.Kind.IsValid() { return nil, errs.New(errs.CodeGitCredentialInvalid, "credential kind invalid") } defaultBr := in.DefaultBranch if defaultBr == "" { defaultBr = "main" } if err := ValidateBranch(defaultBr); err != nil { return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid default branch") } wsID := uuid.New() ws := &Workspace{ ID: wsID, ProjectID: pid, Slug: in.Slug, Name: firstNonEmpty(in.Name, in.Slug), Description: in.Description, GitRemoteURL: in.GitRemoteURL, DefaultBranch: defaultBr, MainPath: MainPath(s.dataDir, wsID), SyncStatus: SyncStatusCloning, } created, err := s.repo.CreateWorkspace(ctx, ws) if err != nil { return nil, err } cred, err := s.encryptForUpsert(wsID, in.Credential) if err != nil { return nil, err } if _, err := s.repo.UpsertCredential(ctx, cred); err != nil { return nil, err } s.audit(ctx, &c.UserID, "workspace.create", "workspace", wsID.String(), map[string]any{"slug": in.Slug}) if s.clones != nil { s.clones.Schedule(wsID) } return created, nil } func (s *workspaceService) Get(ctx context.Context, c Caller, wsID uuid.UUID) (*Workspace, error) { ws, err := s.repo.GetWorkspaceByID(ctx, wsID) if err != nil { return nil, err } canRead, _, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID) if err != nil { return nil, err } if !canRead { return nil, errs.New(errs.CodeForbidden, "no read access") } return ws, nil } func (s *workspaceService) List(ctx context.Context, c Caller, projectSlug string) ([]*Workspace, error) { pid, canRead, _, err := s.pa.Resolve(ctx, c.UserID, c.IsAdmin, projectSlug) if err != nil { return nil, err } if !canRead { return nil, errs.New(errs.CodeForbidden, "no read access") } return s.repo.ListWorkspacesByProject(ctx, pid) } func (s *workspaceService) Update(ctx context.Context, c Caller, wsID uuid.UUID, in UpdateWorkspaceInput) (*Workspace, 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") } name := ws.Name desc := ws.Description defaultBr := ws.DefaultBranch if in.Name != nil { name = *in.Name } if in.Description != nil { desc = *in.Description } if in.DefaultBranch != nil && *in.DefaultBranch != ws.DefaultBranch { // 切换 default_branch:先校验→拉远端→切到新分支,再写 DB。 // 任一 git 步骤失败则不改库,让调用方感知并自行重试/回滚。 if err := ValidateBranch(*in.DefaultBranch); err != nil { return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid default branch") } cred, credErr := s.loadDecryptedCredential(ctx, wsID) if credErr != nil { return nil, credErr } if fetchErr := s.gitr.FetchRemoteBranch(ctx, ws.MainPath, *in.DefaultBranch, cred); fetchErr != nil { return nil, errs.Wrap(fetchErr, errs.CodeGitCmdFailed, "fetch remote branch") } if coErr := s.gitr.Checkout(ctx, ws.MainPath, *in.DefaultBranch); coErr != nil { return nil, errs.Wrap(coErr, errs.CodeGitCmdFailed, "checkout branch") } defaultBr = *in.DefaultBranch } else if in.DefaultBranch != nil { if err := ValidateBranch(*in.DefaultBranch); err != nil { return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid default branch") } defaultBr = *in.DefaultBranch } updated, err := s.repo.UpdateWorkspaceCore(ctx, wsID, name, desc, defaultBr) if err != nil { return nil, err } s.audit(ctx, &c.UserID, "workspace.update", "workspace", wsID.String(), nil) return updated, nil } func (s *workspaceService) Delete(ctx context.Context, c Caller, wsID uuid.UUID) error { ws, err := s.repo.GetWorkspaceByID(ctx, wsID) if err != nil { return err } if _, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID); err != nil { return err } else if !canWrite { return errs.New(errs.CodeForbidden, "no write access") } if err := s.repo.DeleteWorkspace(ctx, wsID); err != nil { return err } // MainPath 是 ${dataDir}//main;删 workspace 时连父目录(含 .worktrees/)一起清掉。 go func(path string) { _ = os.RemoveAll(path) }(filepath.Dir(ws.MainPath)) s.audit(ctx, &c.UserID, "workspace.delete", "workspace", wsID.String(), nil) return nil } func (s *workspaceService) Sync(ctx context.Context, c Caller, wsID uuid.UUID) (*Workspace, 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") } // 先解密凭据,再抢锁置 syncing:避免置 syncing 后 load 失败留下残留。 cred, err := s.loadDecryptedCredential(ctx, wsID) if err != nil { return nil, err } // 原子 CAS 抢锁(WHERE sync_status IN ('idle','error')),消除"读后写"TOCTOU。 // 抢到才继续,否则说明已有 clone/sync 在进行中。 acquired, err := s.repo.MarkSyncing(ctx, wsID) if err != nil { return nil, err } if !acquired { return nil, errs.New(errs.CodeWorkspaceSyncInProgress, "sync already in progress") } // 状态回写与请求生命周期解耦:客户端断开/请求超时也要能写回状态,避免永久卡 syncing。 writeCtx := context.WithoutCancel(ctx) // main 仓库目录缺失(历史 clone 从未成功 / 数据卷丢失)时 fetch 注定失败, // 转为重新 clone 自愈:状态切回 cloning 并调度 CloneRunner,由其落 idle/error。 if _, statErr := os.Stat(ws.MainPath); errors.Is(statErr, os.ErrNotExist) { if s.clones == nil { _, _ = s.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusError, ws.LastSyncedAt, "main repo missing on disk; clone scheduler unavailable") return nil, errs.New(errs.CodeGitCmdFailed, "main repo missing on disk") } updated, err := s.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusCloning, ws.LastSyncedAt, "") if err != nil { return nil, err } s.audit(writeCtx, &c.UserID, "workspace.reclone", "workspace", wsID.String(), map[string]any{"reason": "main path missing"}) s.clones.Schedule(wsID) return updated, nil } fetchCtx, cancel := context.WithTimeout(ctx, s.fetchTimout) defer cancel() if fetchErr := s.gitr.Fetch(fetchCtx, ws.MainPath, cred); fetchErr != nil { errMsg := summarizeErr(fetchErr) _, _ = s.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusError, ws.LastSyncedAt, errMsg) s.audit(writeCtx, &c.UserID, "workspace.sync_failed", "workspace", wsID.String(), map[string]any{"err": errMsg}) return nil, errs.Wrap(fetchErr, errs.CodeGitCmdFailed, "git fetch failed") } // fetch 只更新远端引用,需 ff-only merge 把 main 工作区当前分支快进到远端。 if mergeErr := s.gitr.MergeFFOnly(fetchCtx, ws.MainPath); mergeErr != nil { errMsg := summarizeErr(mergeErr) _, _ = s.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusError, ws.LastSyncedAt, errMsg) s.audit(writeCtx, &c.UserID, "workspace.sync_failed", "workspace", wsID.String(), map[string]any{"err": errMsg}) return nil, errs.Wrap(mergeErr, errs.CodeGitCmdFailed, "git merge --ff-only failed") } now := time.Now().UTC() updated, err := s.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusIdle, &now, "") if err != nil { return nil, err } s.audit(writeCtx, &c.UserID, "workspace.sync", "workspace", wsID.String(), nil) return updated, nil } func (s *workspaceService) UpsertCredential(ctx context.Context, c Caller, wsID uuid.UUID, in UpsertCredentialInput) (*Credential, 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 !in.Kind.IsValid() { return nil, errs.New(errs.CodeGitCredentialInvalid, "kind invalid") } cred, err := s.encryptForUpsert(wsID, in) if err != nil { return nil, err } saved, err := s.repo.UpsertCredential(ctx, cred) if err != nil { return nil, err } s.audit(ctx, &c.UserID, "credential.update", "workspace", wsID.String(), map[string]any{"kind": string(in.Kind)}) saved.Secret = nil return saved, nil } func (s *workspaceService) GetCredentialMeta(ctx context.Context, c Caller, wsID uuid.UUID) (*Credential, 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") } cred, err := s.repo.GetCredentialByWorkspace(ctx, wsID) if err != nil { return nil, err } cred.Secret = nil return cred, nil } func (s *workspaceService) ListBranches(ctx context.Context, c Caller, wsID uuid.UUID) ([]string, 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") } if ws.MainPath == "" { return nil, errs.New(errs.CodeInvalidInput, "workspace not yet cloned") } if _, statErr := os.Stat(ws.MainPath); errors.Is(statErr, os.ErrNotExist) { return nil, errs.New(errs.CodeInvalidInput, "workspace not yet cloned") } cred, err := s.loadDecryptedCredential(ctx, wsID) if err != nil { return nil, err } return s.gitr.ListRemoteBranches(ctx, ws.MainPath, cred) } func (s *workspaceService) encryptForUpsert(wsID uuid.UUID, in UpsertCredentialInput) (*Credential, error) { cred := &Credential{ ID: uuid.New(), WorkspaceID: wsID, Kind: in.Kind, Username: in.Username, } if in.Kind == CredKindNone { return cred, nil } if len(in.Secret) == 0 { return nil, errs.New(errs.CodeGitCredentialInvalid, "secret required") } enc, err := s.enc.Encrypt(in.Secret) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "encrypt secret") } cred.Secret = enc cred.Fingerprint = fingerprint(in.Kind, in.Secret) return cred, nil } // CredentialAccessor 让 app 装配代码不重复实现凭据加载。 // CloneRunner 与 GitOpsService 都通过这个窄接口拿明文 git.Credential。 type CredentialAccessor interface { LoadDecryptedCredential(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) } // SchedulerSetter 让 app 装配代码在创建 cloneRunner 后回填到 service。 // NewWorkspaceService 阶段允许传 nil scheduler;构造完 cloneRunner 再 SetScheduler。 type SchedulerSetter interface { SetScheduler(s CloneScheduler) } // LoadDecryptedCredential 暴露 loadDecryptedCredential 给 app 装配代码(CloneRunner、GitOps 共用)。 func (s *workspaceService) LoadDecryptedCredential(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) { return s.loadDecryptedCredential(ctx, wsID) } // SetScheduler 在装配链路把后构造的 CloneRunner 回填到 service(NewWorkspaceService 阶段允许 nil)。 func (s *workspaceService) SetScheduler(sched CloneScheduler) { s.clones = sched } func (s *workspaceService) loadDecryptedCredential(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) { cred, err := s.repo.GetCredentialByWorkspace(ctx, wsID) if err != nil { var ae *errs.AppError if errors.As(err, &ae) && ae.Code == errs.CodeNotFound { return &git.Credential{Kind: git.CredentialNone}, nil } return nil, err } if cred.Kind == CredKindNone || len(cred.Secret) == 0 { return &git.Credential{Kind: git.CredentialNone}, nil } plain, err := s.enc.Decrypt(cred.Secret) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "decrypt secret") } return &git.Credential{ Kind: git.CredentialKind(cred.Kind), Username: cred.Username, Secret: plain, }, nil } func (s *workspaceService) 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, }) } func firstNonEmpty(a, b string) string { if a != "" { return a } return b } func summarizeErr(err error) string { if err == nil { return "" } s := err.Error() if len(s) > 1000 { return s[:1000] + "...[truncated]" } return s } func fingerprint(kind CredentialKind, secret []byte) string { switch kind { case CredKindPAT: if len(secret) <= 4 { return "****" } return "****" + string(secret[len(secret)-4:]) case CredKindSSHKey: sum := sha256.Sum256(secret) return "SHA256:" + hex.EncodeToString(sum[:])[:16] default: return "" } } var _ = errors.Is var _ = fmt.Sprintf