From bd91880898aa38995e6f271a50f9471a909b4708 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Sat, 2 May 2026 07:50:49 +0800 Subject: [PATCH] feat(workspace): WorkspaceService + async clone runner --- internal/workspace/clone_runner.go | 88 +++++ internal/workspace/workspace_service.go | 370 +++++++++++++++++++ internal/workspace/workspace_service_test.go | 269 ++++++++++++++ 3 files changed, 727 insertions(+) create mode 100644 internal/workspace/clone_runner.go create mode 100644 internal/workspace/workspace_service.go create mode 100644 internal/workspace/workspace_service_test.go diff --git a/internal/workspace/clone_runner.go b/internal/workspace/clone_runner.go new file mode 100644 index 0000000..e53a234 --- /dev/null +++ b/internal/workspace/clone_runner.go @@ -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()) + } +} diff --git a/internal/workspace/workspace_service.go b/internal/workspace/workspace_service.go new file mode 100644 index 0000000..701dcc5 --- /dev/null +++ b/internal/workspace/workspace_service.go @@ -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}//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 diff --git a/internal/workspace/workspace_service_test.go b/internal/workspace/workspace_service_test.go new file mode 100644 index 0000000..3bc7d3b --- /dev/null +++ b/internal/workspace/workspace_service_test.go @@ -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") +}