You've already forked agentic-coding-workflow
feat(workspace): WorkspaceService + async clone runner
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CloneRunner 用 goroutine 异步跑 git clone。Schedule 是非阻塞的:
|
||||||
|
// 调用方立即返回,clone 进度通过 sync_status 列轮询。
|
||||||
|
//
|
||||||
|
// Plan #5(Job Runner)实现后会替换 Schedule 内部为 job dispatcher 入队,
|
||||||
|
// 调用点不需要改动。
|
||||||
|
type CloneRunner struct {
|
||||||
|
repo Repository
|
||||||
|
rec audit.Recorder
|
||||||
|
gitr git.Runner
|
||||||
|
log *slog.Logger
|
||||||
|
timeout time.Duration
|
||||||
|
|
||||||
|
wg sync.WaitGroup
|
||||||
|
resolveCred func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCloneRunner 构造 CloneRunner。timeout==0 时使用默认 30 分钟超时。
|
||||||
|
func NewCloneRunner(
|
||||||
|
repo Repository, rec audit.Recorder, gitr git.Runner, log *slog.Logger,
|
||||||
|
resolveCred func(context.Context, uuid.UUID) (*git.Credential, error),
|
||||||
|
timeout time.Duration,
|
||||||
|
) *CloneRunner {
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 30 * time.Minute
|
||||||
|
}
|
||||||
|
return &CloneRunner{
|
||||||
|
repo: repo, rec: rec, gitr: gitr, log: log,
|
||||||
|
timeout: timeout, resolveCred: resolveCred,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule 非阻塞地排程一次 clone;调用方立即返回。
|
||||||
|
func (r *CloneRunner) Schedule(wsID uuid.UUID) {
|
||||||
|
r.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer r.wg.Done()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
|
||||||
|
defer cancel()
|
||||||
|
r.run(ctx, wsID)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait 阻塞直到所有已排程的 clone 任务完成;测试与 graceful shutdown 用。
|
||||||
|
func (r *CloneRunner) Wait() { r.wg.Wait() }
|
||||||
|
|
||||||
|
func (r *CloneRunner) run(ctx context.Context, wsID uuid.UUID) {
|
||||||
|
ws, err := r.repo.GetWorkspaceByID(ctx, wsID)
|
||||||
|
if err != nil {
|
||||||
|
r.log.Error("clone runner: get workspace", "ws_id", wsID, "err", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cred, err := r.resolveCred(ctx, wsID)
|
||||||
|
if err != nil {
|
||||||
|
r.markError(ctx, wsID, "resolve credential: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cloneErr := r.gitr.Clone(ctx, ws.MainPath, ws.GitRemoteURL, ws.DefaultBranch, cred); cloneErr != nil {
|
||||||
|
r.markError(ctx, wsID, summarizeErr(cloneErr))
|
||||||
|
_ = r.rec.Record(ctx, audit.Entry{Action: "workspace.clone_failed", TargetType: "workspace", TargetID: wsID.String(), Metadata: map[string]any{"err": summarizeErr(cloneErr)}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if _, err := r.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusIdle, &now, ""); err != nil {
|
||||||
|
r.log.Error("clone runner: update sync status idle", "ws_id", wsID, "err", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = r.rec.Record(ctx, audit.Entry{Action: "workspace.clone_ok", TargetType: "workspace", TargetID: wsID.String()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CloneRunner) markError(ctx context.Context, wsID uuid.UUID, msg string) {
|
||||||
|
if _, err := r.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusError, nil, msg); err != nil {
|
||||||
|
r.log.Error("clone runner: update sync status error", "ws_id", wsID, "err", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
wss map[uuid.UUID]*Workspace
|
||||||
|
creds map[uuid.UUID]*Credential
|
||||||
|
wts map[uuid.UUID]*Worktree
|
||||||
|
errOn map[string]error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeRepo() *fakeRepo {
|
||||||
|
return &fakeRepo{
|
||||||
|
wss: map[uuid.UUID]*Workspace{}, creds: map[uuid.UUID]*Credential{},
|
||||||
|
wts: map[uuid.UUID]*Worktree{}, errOn: map[string]error{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) CreateWorkspace(_ context.Context, w *Workspace) (*Workspace, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if e := f.errOn["create"]; e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
cp := *w
|
||||||
|
f.wss[w.ID] = &cp
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) GetWorkspaceByID(_ context.Context, id uuid.UUID) (*Workspace, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if w, ok := f.wss[id]; ok {
|
||||||
|
cp := *w
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("not found")
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) GetWorkspaceBySlug(_ context.Context, _, _ string) (*Workspace, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) ListWorkspacesByProject(_ context.Context, _ uuid.UUID) ([]*Workspace, error) {
|
||||||
|
out := make([]*Workspace, 0, len(f.wss))
|
||||||
|
for _, w := range f.wss {
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) UpdateWorkspaceCore(_ context.Context, id uuid.UUID, name, desc, br string) (*Workspace, error) {
|
||||||
|
w := f.wss[id]
|
||||||
|
w.Name, w.Description, w.DefaultBranch = name, desc, br
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) UpdateWorkspaceSyncStatus(_ context.Context, id uuid.UUID, st SyncStatus, _ *time.Time, msg string) (*Workspace, error) {
|
||||||
|
w := f.wss[id]
|
||||||
|
w.SyncStatus, w.LastSyncError = st, msg
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) DeleteWorkspace(_ context.Context, id uuid.UUID) error {
|
||||||
|
delete(f.wss, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) ResetStuckSyncStatuses(_ context.Context) error { return nil }
|
||||||
|
func (f *fakeRepo) GetWorktreeByID(_ context.Context, id uuid.UUID) (*Worktree, error) {
|
||||||
|
if w, ok := f.wts[id]; ok {
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("not found")
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) ListWorktreesByWorkspace(_ context.Context, _ uuid.UUID) ([]*Worktree, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) DeleteWorktree(_ context.Context, _ uuid.UUID) error { return nil }
|
||||||
|
func (f *fakeRepo) UpsertCredential(_ context.Context, c *Credential) (*Credential, error) {
|
||||||
|
cp := *c
|
||||||
|
f.creds[c.WorkspaceID] = &cp
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) GetCredentialByWorkspace(_ context.Context, ws uuid.UUID) (*Credential, error) {
|
||||||
|
if c, ok := f.creds[ws]; ok {
|
||||||
|
cp := *c
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
return nil, errs.New(errs.CodeNotFound, "credential not found")
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) InTx(_ context.Context, fn func(Tx) error) error { return fn(&fakeTx{f: f}) }
|
||||||
|
|
||||||
|
type fakeTx struct{ f *fakeRepo }
|
||||||
|
|
||||||
|
func (t *fakeTx) GetWorktreeForUpdate(_ context.Context, ws uuid.UUID, br string) (*Worktree, error) {
|
||||||
|
for _, w := range t.f.wts {
|
||||||
|
if w.WorkspaceID == ws && w.Branch == br {
|
||||||
|
cp := *w
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, &errsNotFound{}
|
||||||
|
}
|
||||||
|
func (t *fakeTx) GetWorktreeByID(_ context.Context, id uuid.UUID) (*Worktree, error) {
|
||||||
|
if w, ok := t.f.wts[id]; ok {
|
||||||
|
cp := *w
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
return nil, &errsNotFound{}
|
||||||
|
}
|
||||||
|
func (t *fakeTx) InsertWorktree(_ context.Context, w *Worktree) (*Worktree, error) {
|
||||||
|
cp := *w
|
||||||
|
t.f.wts[w.ID] = &cp
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
func (t *fakeTx) SetWorktreeActive(_ context.Context, id uuid.UUID, holder string) (*Worktree, error) {
|
||||||
|
w := t.f.wts[id]
|
||||||
|
w.Status, w.ActiveHolder = WorktreeStatusActive, holder
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
func (t *fakeTx) SetWorktreeIdle(_ context.Context, id uuid.UUID) (*Worktree, error) {
|
||||||
|
w := t.f.wts[id]
|
||||||
|
w.Status, w.ActiveHolder = WorktreeStatusIdle, ""
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
func (t *fakeTx) SetWorktreePruning(_ context.Context, id uuid.UUID) (*Worktree, error) {
|
||||||
|
w := t.f.wts[id]
|
||||||
|
w.Status = WorktreeStatusPruning
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type errsNotFound struct{}
|
||||||
|
|
||||||
|
func (e *errsNotFound) Error() string { return "not found" }
|
||||||
|
|
||||||
|
type fakePA struct {
|
||||||
|
pid uuid.UUID
|
||||||
|
canRead bool
|
||||||
|
canWrite bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePA) Resolve(_ context.Context, _ uuid.UUID, _ bool, _ string) (uuid.UUID, bool, bool, error) {
|
||||||
|
return f.pid, f.canRead, f.canWrite, nil
|
||||||
|
}
|
||||||
|
func (f *fakePA) ResolveByID(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) (bool, bool, error) {
|
||||||
|
return f.canRead, f.canWrite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeSched struct{ called []uuid.UUID }
|
||||||
|
|
||||||
|
func (s *fakeSched) Schedule(id uuid.UUID) { s.called = append(s.called, id) }
|
||||||
|
|
||||||
|
type fakeGit struct {
|
||||||
|
cloneErr error
|
||||||
|
fetchErr error
|
||||||
|
cloneSeen []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGit) Clone(_ context.Context, dst, _, _ string, _ *git.Credential) error {
|
||||||
|
f.cloneSeen = append(f.cloneSeen, dst)
|
||||||
|
return f.cloneErr
|
||||||
|
}
|
||||||
|
func (f *fakeGit) Fetch(_ context.Context, _ string, _ *git.Credential) error { return f.fetchErr }
|
||||||
|
func (f *fakeGit) Push(_ context.Context, _, _ string, _ *git.Credential) error { return nil }
|
||||||
|
func (f *fakeGit) Commit(_ context.Context, _, _ string, _ bool) (string, error) {
|
||||||
|
return "deadbeef", nil
|
||||||
|
}
|
||||||
|
func (f *fakeGit) Status(_ context.Context, _ string) ([]git.FileStatus, error) { return nil, nil }
|
||||||
|
func (f *fakeGit) WorktreeAdd(_ context.Context, _, _, _ string) error { return nil }
|
||||||
|
func (f *fakeGit) WorktreeRemove(_ context.Context, _, _ string) error { return nil }
|
||||||
|
func (f *fakeGit) WorktreeList(_ context.Context, _ string) ([]git.Worktree, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type noopAudit struct{}
|
||||||
|
|
||||||
|
func (noopAudit) Record(_ context.Context, _ audit.Entry) error { return nil }
|
||||||
|
|
||||||
|
func newSvc(t *testing.T, pa *fakePA, sched CloneScheduler, gitr git.Runner) (*workspaceService, *fakeRepo) {
|
||||||
|
t.Helper()
|
||||||
|
enc, err := crypto.NewEncryptor(make([]byte, 32))
|
||||||
|
require.NoError(t, err)
|
||||||
|
repo := newFakeRepo()
|
||||||
|
svc := NewWorkspaceService(repo, noopAudit{}, enc, gitr, pa, sched, "/data").(*workspaceService)
|
||||||
|
return svc, repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceService_Create_HappyPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
|
||||||
|
sched := &fakeSched{}
|
||||||
|
svc, repo := newSvc(t, pa, sched, &fakeGit{})
|
||||||
|
caller := Caller{UserID: uuid.New()}
|
||||||
|
ws, err := svc.Create(context.Background(), caller, "p", CreateWorkspaceInput{
|
||||||
|
Slug: "ws1", Name: "W1", GitRemoteURL: "https://x", DefaultBranch: "main",
|
||||||
|
Credential: UpsertCredentialInput{Kind: CredKindNone},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, SyncStatusCloning, ws.SyncStatus)
|
||||||
|
require.Len(t, sched.called, 1)
|
||||||
|
require.Equal(t, ws.ID, sched.called[0])
|
||||||
|
_, ok := repo.creds[ws.ID]
|
||||||
|
require.True(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceService_Create_BadSlug(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}
|
||||||
|
svc, _ := newSvc(t, pa, &fakeSched{}, &fakeGit{})
|
||||||
|
_, err := svc.Create(context.Background(), Caller{UserID: uuid.New()}, "p", CreateWorkspaceInput{
|
||||||
|
Slug: "BAD-SLUG", Credential: UpsertCredentialInput{Kind: CredKindNone},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceService_Sync_BlockedWhenCloning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}
|
||||||
|
svc, repo := newSvc(t, pa, &fakeSched{}, &fakeGit{})
|
||||||
|
id := uuid.New()
|
||||||
|
repo.wss[id] = &Workspace{ID: id, ProjectID: pa.pid, MainPath: "/x", SyncStatus: SyncStatusCloning}
|
||||||
|
_, err := svc.Sync(context.Background(), Caller{UserID: uuid.New()}, id)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloneRunner_HappyPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}
|
||||||
|
gitr := &fakeGit{}
|
||||||
|
svc, repo := newSvc(t, pa, &fakeSched{}, gitr)
|
||||||
|
id := uuid.New()
|
||||||
|
repo.wss[id] = &Workspace{ID: id, MainPath: "/data/main", DefaultBranch: "main", SyncStatus: SyncStatusCloning}
|
||||||
|
runner := NewCloneRunner(repo, noopAudit{}, gitr, slog.Default(),
|
||||||
|
func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) {
|
||||||
|
return svc.loadDecryptedCredential(ctx, wsID)
|
||||||
|
}, 0)
|
||||||
|
runner.Schedule(id)
|
||||||
|
runner.Wait()
|
||||||
|
got, _ := repo.GetWorkspaceByID(context.Background(), id)
|
||||||
|
require.Equal(t, SyncStatusIdle, got.SyncStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloneRunner_Failure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}
|
||||||
|
gitr := &fakeGit{cloneErr: errors.New("boom")}
|
||||||
|
svc, repo := newSvc(t, pa, &fakeSched{}, gitr)
|
||||||
|
id := uuid.New()
|
||||||
|
repo.wss[id] = &Workspace{ID: id, MainPath: "/x", DefaultBranch: "main", SyncStatus: SyncStatusCloning}
|
||||||
|
runner := NewCloneRunner(repo, noopAudit{}, gitr, slog.Default(),
|
||||||
|
func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) {
|
||||||
|
return svc.loadDecryptedCredential(ctx, wsID)
|
||||||
|
}, 0)
|
||||||
|
runner.Schedule(id)
|
||||||
|
runner.Wait()
|
||||||
|
got, _ := repo.GetWorkspaceByID(context.Background(), id)
|
||||||
|
require.Equal(t, SyncStatusError, got.SyncStatus)
|
||||||
|
require.Contains(t, got.LastSyncError, "boom")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user