You've already forked agentic-coding-workflow
feat(workspace): WorkspaceService + async clone runner
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
// 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}/<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")
|
||||
}
|
||||
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
|
||||
Reference in New Issue
Block a user