// 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}) 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}//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") } if ws.SyncStatus == SyncStatusCloning || ws.SyncStatus == SyncStatusSyncing { return nil, errs.New(errs.CodeWorkspaceSyncInProgress, "sync already in progress") } if _, err := s.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusSyncing, ws.LastSyncedAt, ""); err != nil { return nil, err } cred, err := s.loadDecryptedCredential(ctx, wsID) if err != nil { return nil, err } fetchCtx, cancel := context.WithTimeout(ctx, s.fetchTimout) defer cancel() fetchErr := s.gitr.Fetch(fetchCtx, ws.MainPath, cred) if fetchErr != nil { errMsg := summarizeErr(fetchErr) _, _ = s.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusError, ws.LastSyncedAt, errMsg) s.audit(ctx, &c.UserID, "workspace.sync_failed", "workspace", wsID.String(), map[string]any{"err": errMsg}) return nil, errs.Wrap(fetchErr, errs.CodeGitCmdFailed, "git fetch failed") } now := time.Now().UTC() updated, err := s.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusIdle, &now, "") if err != nil { return nil, err } s.audit(ctx, &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 } 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