Files
agentic-coding-workflow/internal/workspace/workspace_service.go
T
2026-06-10 15:30:52 +08:00

407 lines
13 KiB
Go

// 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 {
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}/<wsID>/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)
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) 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