feat(workspace): WorkspaceService + async clone runner

This commit is contained in:
2026-05-02 07:50:49 +08:00
parent 64dd560b55
commit bd91880898
3 changed files with 727 additions and 0 deletions
+88
View File
@@ -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())
}
}
+370
View File
@@ -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")
}