feat(app): wire workspace module + integration test for ws closed loop

This commit is contained in:
2026-05-02 09:39:27 +08:00
parent 78e74c9734
commit 7429a817bc
9 changed files with 584 additions and 1 deletions
+46
View File
@@ -0,0 +1,46 @@
# AgentCodingWorkflow 配置文件示例
#
# 用法:
# 1. 复制为 config.yaml(已被 .env / 真实凭据建议放在 .gitignore 之外管理)
# 2. 启动时设置环境变量:APP_CONFIG_FILE=./config.yaml ./bin/server
#
# 加载优先级(后者覆盖前者):默认值 → 本配置文件 → 环境变量(APP_ 前缀)
# 即同名键存在 APP_xxx 环境变量时,会覆盖此处的值。
# 运行环境:development / production / test
env: development
# 本地数据目录(用于落地文件、缓存等)
data_dir: ./data
# 主加密密钥(必填):32 字节的 hex 编码字符串,长度恰好 64 个十六进制字符
# 生成示例:openssl rand -hex 32
master_key: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
http:
# HTTP 监听地址,格式 host:port,留空 host 表示监听所有网卡
addr: ":8080"
db:
# PostgreSQL DSN(必填)
dsn: "postgres://acw:acw@localhost:5432/acw?sslmode=disable"
# 首次启动时的管理员种子账号
bootstrap:
admin_email: "admin@local"
admin_password: "changeme"
# Workspace 模块(git clone / worktree 落盘 + 各 git verb 超时)
workspace:
# workspace clone/worktree 的根目录(程序会在其下自建 workspaces/ 与 tmp/ 子目录)
data_dir: "./data"
# 大仓库 clone 上限,按需调高;timeout 走 ctx 优先于 git 进程超时
clone_timeout: "30m"
fetch_timeout: "5m"
push_timeout: "5m"
# commit / status / worktree 等轻量操作
ops_timeout: "60s"
# git CLI 配置(默认走 PATH 里的 git)
git:
binary: "git"
+120
View File
@@ -24,18 +24,23 @@ import (
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"path/filepath"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/config" "github.com/yan1h/agent-coding-workflow/internal/config"
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
"github.com/yan1h/agent-coding-workflow/internal/infra/db" "github.com/yan1h/agent-coding-workflow/internal/infra/db"
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
"github.com/yan1h/agent-coding-workflow/internal/infra/logger" "github.com/yan1h/agent-coding-workflow/internal/infra/logger"
"github.com/yan1h/agent-coding-workflow/internal/infra/notify" "github.com/yan1h/agent-coding-workflow/internal/infra/notify"
"github.com/yan1h/agent-coding-workflow/internal/project" "github.com/yan1h/agent-coding-workflow/internal/project"
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
"github.com/yan1h/agent-coding-workflow/internal/user" "github.com/yan1h/agent-coding-workflow/internal/user"
"github.com/yan1h/agent-coding-workflow/internal/workspace"
) )
// App 持有运行期所有需要被关停的资源。字段保持非导出,避免外部直接操作。 // App 持有运行期所有需要被关停的资源。字段保持非导出,避免外部直接操作。
@@ -103,6 +108,71 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
notify.NewHandler(notifyRepo, userSvc).Mount(r) notify.NewHandler(notifyRepo, userSvc).Mount(r)
project.NewHandler(projectSvc, requirementSvc, issueSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) project.NewHandler(projectSvc, requirementSvc, issueSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
// ===== workspace 模块装配 =====
// 依赖图:
// git.Runner (cfg) ─┐
// crypto.Encryptor ─┼─ workspaceService (clones=nil) ─┐
// workspace.Repo ───┘ ├─ CloneRunner ─┐
// │ │
// SetScheduler ───┴───────────────┘
// │
// WorktreeService / GitOpsService
// wsDataDir 是数据根;pathing.MainPath/WorktreePath 内部会再 join "workspaces" 子目录。
// 这里显式 mkdir 出 ${dataDir}/workspaces 让首个 clone 的父目录已经存在。
wsDataDir := cfg.Workspace.DataDir
if err := os.MkdirAll(filepath.Join(wsDataDir, "workspaces"), 0o755); err != nil {
return nil, fmt.Errorf("mkdir workspace data dir: %w", err)
}
tmpDir := filepath.Join(wsDataDir, "tmp")
// 启动期清掉残留 tmp(可能含上次崩溃留下的 askpass 脚本与 SSH 私钥)
_ = os.RemoveAll(tmpDir)
if err := os.MkdirAll(tmpDir, 0o700); err != nil {
return nil, fmt.Errorf("mkdir workspace tmp: %w", err)
}
enc, err := crypto.NewEncryptor(cfg.MasterKey)
if err != nil {
return nil, fmt.Errorf("crypto encryptor: %w", err)
}
gitr := git.NewDefaultRunner(git.Config{
Binary: cfg.Git.Binary,
TmpDir: tmpDir,
CloneTimeout: cfg.Workspace.CloneTimeout,
FetchTimeout: cfg.Workspace.FetchTimeout,
PushTimeout: cfg.Workspace.PushTimeout,
OpsTimeout: cfg.Workspace.OpsTimeout,
})
wsRepo := workspace.NewPostgresRepository(pool)
// 进程启动时把卡死的 cloning/syncing 复位为 error,避免行永远卡住
if err := wsRepo.ResetStuckSyncStatuses(ctx); err != nil {
log.Warn("reset stuck workspace sync statuses", "err", err.Error())
}
pa := projectAccessAdapter{p: projectSvc}
// 先以 nil scheduler 构造 service,cloneRunner 再回填(环依赖:runner 需要凭据访问器,访问器在 service 上)
wsSvc := workspace.NewWorkspaceService(wsRepo, auditRec, enc, gitr, pa, nil, wsDataDir)
// 共享凭据加载器:CloneRunner 与 GitOpsService 都需要。
// 通过 CredentialAccessor 接口避免直接持有 *workspaceService 私有类型。
credAccessor, ok := wsSvc.(workspace.CredentialAccessor)
if !ok {
return nil, fmt.Errorf("workspace service does not implement CredentialAccessor")
}
credLoader := func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) {
return credAccessor.LoadDecryptedCredential(ctx, wsID)
}
cloneRunner := workspace.NewCloneRunner(wsRepo, auditRec, gitr, log, credLoader, cfg.Workspace.CloneTimeout)
if setter, ok := wsSvc.(workspace.SchedulerSetter); ok {
setter.SetScheduler(cloneRunner)
}
wtSvc := workspace.NewWorktreeService(wsRepo, auditRec, gitr, pa, wsDataDir)
gopSvc := workspace.NewGitOpsService(wsRepo, auditRec, gitr, pa, credLoader)
workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
if cfg.Bootstrap.AdminEmail != "" && cfg.Bootstrap.AdminPassword != "" { if cfg.Bootstrap.AdminEmail != "" && cfg.Bootstrap.AdminPassword != "" {
if u, err := userSvc.Bootstrap(ctx, cfg.Bootstrap.AdminEmail, cfg.Bootstrap.AdminPassword); err != nil { if u, err := userSvc.Bootstrap(ctx, cfg.Bootstrap.AdminEmail, cfg.Bootstrap.AdminPassword); err != nil {
log.Warn("bootstrap admin failed", "err", err.Error()) log.Warn("bootstrap admin failed", "err", err.Error())
@@ -160,6 +230,8 @@ func (a *App) Run(ctx context.Context) error {
// userAdminAdapter 把 user.Service 适配为 project.AdminLookup(窄接口)。 // userAdminAdapter 把 user.Service 适配为 project.AdminLookup(窄接口)。
// 复用 user.Service.Get:返回的 *User 直接读 IsAdmin。 // 复用 user.Service.Get:返回的 *User 直接读 IsAdmin。
// workspace.AdminLookup 与 project.AdminLookup 同形(IsAdmin 同名同签名),
// 因此本类型同时满足两者,可在两个 handler 复用。
type userAdminAdapter struct{ svc user.Service } type userAdminAdapter struct{ svc user.Service }
func (a userAdminAdapter) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) { func (a userAdminAdapter) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
@@ -169,3 +241,51 @@ func (a userAdminAdapter) IsAdmin(ctx context.Context, id uuid.UUID) (bool, erro
} }
return u.IsAdmin, nil return u.IsAdmin, nil
} }
// projectAccessAdapter 把 project.ProjectService.Get / GetByID 桥接成 workspace.ProjectAccess。
// - Resolve = by slug:用于 POST /projects/{slug}/workspaces 等路径
// - ResolveByID = by uuid:用于 wsID 拿到 ws 后再校验 project 权限
//
// ACL 语义复刻 project 包内部 assertReadable / assertWritable:
//
// read private: owner + admin
// read internal: 任意已登录
// write *: owner + admin
//
// 注意:project.assertReadable 在 visibility=private 且非 owner+admin 时返回 NotFound
// 防探测;这里 ProjectService.Get/GetByID 已替我们兜住,到本适配层时 pj 一定可读。
type projectAccessAdapter struct {
p project.ProjectService
}
func (a projectAccessAdapter) Resolve(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectSlug string) (uuid.UUID, bool, bool, error) {
pj, err := a.p.Get(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectSlug)
if err != nil {
return uuid.Nil, false, false, err
}
canRead, canWrite := projectACL(pj, callerID, isAdmin)
return pj.ID, canRead, canWrite, nil
}
func (a projectAccessAdapter) ResolveByID(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, bool, error) {
pj, err := a.p.GetByID(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectID)
if err != nil {
return false, false, err
}
canRead, canWrite := projectACL(pj, callerID, isAdmin)
return canRead, canWrite, nil
}
// projectACL 复制 project 包内部 assertReadable/assertWritable 的语义:
// - admin 永远可读可写
// - owner 可读可写
// - internal 可见性:所有登录用户可读
//
// 注:写操作还需考虑 archived 状态;workspace 的写场景会在 service 层用 ProjectAccess
// 拿到 canWrite 后再下沉,archived 检查由 project service 在 GetByID 已可见。
func projectACL(pj *project.Project, callerID uuid.UUID, isAdmin bool) (canRead, canWrite bool) {
owned := pj.OwnerID == callerID
canWrite = isAdmin || owned
canRead = canWrite || pj.Visibility == project.VisibilityInternal
return canRead, canWrite
}
+291
View File
@@ -12,8 +12,12 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"os/exec"
"path/filepath"
"testing" "testing"
"time" "time"
@@ -23,12 +27,15 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/audit" "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/db" "github.com/yan1h/agent-coding-workflow/internal/infra/db"
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
"github.com/yan1h/agent-coding-workflow/internal/infra/logger" "github.com/yan1h/agent-coding-workflow/internal/infra/logger"
"github.com/yan1h/agent-coding-workflow/internal/infra/notify" "github.com/yan1h/agent-coding-workflow/internal/infra/notify"
"github.com/yan1h/agent-coding-workflow/internal/project" "github.com/yan1h/agent-coding-workflow/internal/project"
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
"github.com/yan1h/agent-coding-workflow/internal/user" "github.com/yan1h/agent-coding-workflow/internal/user"
"github.com/yan1h/agent-coding-workflow/internal/workspace"
) )
func TestEndToEnd_LoginAndDispatchNotification(t *testing.T) { func TestEndToEnd_LoginAndDispatchNotification(t *testing.T) {
@@ -208,3 +215,287 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.GreaterOrEqual(t, count, 8, "expected at least 8 audit log entries for project/requirement/issue actions") require.GreaterOrEqual(t, count, 8, "expected at least 8 audit log entries for project/requirement/issue actions")
} }
// projectAccessAdapterTest 把 project.ProjectService.Get / GetByID 桥接成 workspace.ProjectAccess。
// 与 internal/app/app.go 里的 projectAccessAdapter 同形(拷贝粘贴),保持 test-internal 不导出。
type projectAccessAdapterTest struct {
p project.ProjectService
}
func (a projectAccessAdapterTest) Resolve(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectSlug string) (uuid.UUID, bool, bool, error) {
pj, err := a.p.Get(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectSlug)
if err != nil {
return uuid.Nil, false, false, err
}
canRead, canWrite := projectACLTest(pj, callerID, isAdmin)
return pj.ID, canRead, canWrite, nil
}
func (a projectAccessAdapterTest) ResolveByID(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, bool, error) {
pj, err := a.p.GetByID(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectID)
if err != nil {
return false, false, err
}
canRead, canWrite := projectACLTest(pj, callerID, isAdmin)
return canRead, canWrite, nil
}
func projectACLTest(pj *project.Project, callerID uuid.UUID, isAdmin bool) (canRead, canWrite bool) {
owned := pj.OwnerID == callerID
canWrite = isAdmin || owned
canRead = canWrite || pj.Visibility == project.VisibilityInternal
return canRead, canWrite
}
// makeLocalBareRepo 在临时目录里建一个带 main 分支与一次 commit 的 bare 仓库,
// 返回路径(用 file:// 协议作为 git_remote_url,让 Plan #3 的 clone 走 file 协议)。
func makeLocalBareRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
bare := filepath.Join(dir, "remote.git")
require.NoError(t, exec.Command("git", "init", "--bare", "-b", "main", bare).Run())
work := filepath.Join(dir, "seed")
require.NoError(t, os.MkdirAll(work, 0o755))
for _, args := range [][]string{
{"init", "-b", "main"},
{"config", "user.email", "t@e.c"},
{"config", "user.name", "T"},
} {
c := exec.Command("git", args...)
c.Dir = work
require.NoError(t, c.Run())
}
require.NoError(t, os.WriteFile(filepath.Join(work, "README.md"), []byte("hi\n"), 0o644))
for _, args := range [][]string{
{"add", "."},
{"commit", "-m", "init"},
{"remote", "add", "origin", bare},
{"push", "origin", "main"},
} {
c := exec.Command("git", args...)
c.Dir = work
require.NoError(t, c.Run())
}
return bare
}
// TestPlan3_WorkspaceClosedLoop 端到端覆盖 workspace 模块:
// 1. bootstrap admin → login 拿 token
// 2. 创建 project + bare 远端
// 3. POST /workspaces 创建 workspace(异步 clone)
// 4. 轮询 GET /workspaces/{id} 直到 sync_status == "idle"
// 5. POST /worktrees 创建支线 → acquire/release 状态机切换
// 6. POST /sync(等同 git fetch)→ sync_status 仍为 idle
// 7. DELETE worktree → DELETE workspace 都返回 204
//
// 跳过条件:本机没有 git 二进制时直接 t.Skip。Docker 不可用会让 testcontainers
// Run 失败,由上层的 require 触发测试失败(与已有 PM 闭环测试一致)。
func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
ctx := context.Background()
container, err := tcpg.Run(ctx, "postgres:16-alpine",
tcpg.WithDatabase("acw"),
tcpg.WithUsername("acw"),
tcpg.WithPassword("acw"),
)
require.NoError(t, err)
t.Cleanup(func() { _ = container.Terminate(ctx) })
dsn, err := container.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
require.NoError(t, db.Migrate(ctx, dsn, "file://../../migrations"))
pool, err := db.NewPool(ctx, dsn)
require.NoError(t, err)
t.Cleanup(pool.Close)
log := logger.New(logger.Options{Format: logger.FormatJSON, Level: logger.LevelInfo})
auditRec := audit.NewPostgresRecorder(pool)
userRepo := user.NewPostgresRepository(pool)
userSvc := user.NewService(userRepo, auditRec)
projectRepo := project.NewPostgresRepository(pool)
projectSvc := project.NewProjectService(projectRepo, auditRec)
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
issueSvc := project.NewIssueService(projectRepo, auditRec)
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
require.NoError(t, err)
require.NotNil(t, adminUser)
// ---- workspace 模块装配(与 app.go 同形,但不依赖 cfg) ----
dataDir := t.TempDir()
tmpDir := filepath.Join(dataDir, "tmp")
require.NoError(t, os.MkdirAll(tmpDir, 0o700))
enc, err := crypto.NewEncryptor(make([]byte, 32))
require.NoError(t, err)
gitr := git.NewDefaultRunner(git.Config{
Binary: "git", TmpDir: tmpDir,
CloneTimeout: 30 * time.Second, FetchTimeout: 30 * time.Second,
PushTimeout: 30 * time.Second, OpsTimeout: 30 * time.Second,
})
wsRepo := workspace.NewPostgresRepository(pool)
require.NoError(t, wsRepo.ResetStuckSyncStatuses(ctx))
pa := projectAccessAdapterTest{p: projectSvc}
wsSvc := workspace.NewWorkspaceService(wsRepo, auditRec, enc, gitr, pa, nil, dataDir)
credAcc, ok := wsSvc.(workspace.CredentialAccessor)
require.True(t, ok)
credLoader := func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) {
return credAcc.LoadDecryptedCredential(ctx, wsID)
}
cloneRunner := workspace.NewCloneRunner(wsRepo, auditRec, gitr, log, credLoader, 30*time.Second)
if setter, ok := wsSvc.(workspace.SchedulerSetter); ok {
setter.SetScheduler(cloneRunner)
}
wtSvc := workspace.NewWorktreeService(wsRepo, auditRec, gitr, pa, dataDir)
gopSvc := workspace.NewGitOpsService(wsRepo, auditRec, gitr, pa, credLoader)
r := chi.NewRouter()
r.Use(middleware.RequestID)
user.NewHandler(userSvc).Mount(r)
project.NewHandler(projectSvc, requirementSvc, issueSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
srv := httptest.NewServer(r)
t.Cleanup(srv.Close)
// ---- 登录拿 token ----
loginBody, _ := json.Marshal(map[string]string{"email": "admin@local", "password": "password123"})
loginResp, err := http.Post(srv.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBody))
require.NoError(t, err)
require.Equal(t, http.StatusOK, loginResp.StatusCode)
var lr struct {
Token string `json:"token"`
}
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&lr))
_ = loginResp.Body.Close()
require.NotEmpty(t, lr.Token)
token := lr.Token
// 三个内联 helper:内联是有意为之,避免引入 test 外部 helper 包。
postJSON := func(path string, body any) (*http.Response, []byte) {
t.Helper()
b, _ := json.Marshal(body)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
buf, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp, buf
}
getJSON := func(path string) (*http.Response, []byte) {
t.Helper()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
buf, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp, buf
}
deleteJSON := func(path string) *http.Response {
t.Helper()
req, _ := http.NewRequestWithContext(ctx, http.MethodDelete, srv.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
return resp
}
// ---- 1. 创建 project ----
resp, _ := postJSON("/api/v1/projects/", map[string]string{"slug": "p1", "name": "P1"})
require.Equal(t, http.StatusCreated, resp.StatusCode)
// ---- 2. 准备本地 bare 仓库(file://协议) ----
bare := makeLocalBareRepo(t)
remoteURL := "file://" + filepath.ToSlash(bare)
// ---- 3. 创建 workspace(credential.kind=none,本地 file 协议无需凭据) ----
resp, body := postJSON("/api/v1/projects/p1/workspaces", map[string]any{
"slug": "ws1", "name": "WS1",
"git_remote_url": remoteURL,
"default_branch": "main",
"credential": map[string]any{"kind": "none"},
})
require.Equalf(t, http.StatusCreated, resp.StatusCode, "create workspace failed: %s", string(body))
var wsResp struct {
ID uuid.UUID `json:"id"`
SyncStatus string `json:"sync_status"`
}
require.NoError(t, json.Unmarshal(body, &wsResp))
require.NotEqual(t, uuid.Nil, wsResp.ID)
// ---- 4. 等 clone 完成 → sync_status = idle ----
require.Eventuallyf(t, func() bool {
r, b := getJSON("/api/v1/workspaces/" + wsResp.ID.String())
if r.StatusCode != http.StatusOK {
return false
}
var ws struct {
SyncStatus string `json:"sync_status"`
LastSyncError string `json:"last_sync_error"`
}
_ = json.Unmarshal(b, &ws)
if ws.SyncStatus == "error" {
t.Logf("clone errored: %s", ws.LastSyncError)
return false
}
return ws.SyncStatus == "idle"
}, 30*time.Second, 200*time.Millisecond, "workspace clone did not become idle")
// ---- 5. 创建 worktree feat-x ----
resp, body = postJSON("/api/v1/workspaces/"+wsResp.ID.String()+"/worktrees", map[string]string{
"branch": "feat-x", "base_branch": "main",
})
require.Equalf(t, http.StatusCreated, resp.StatusCode, "create worktree: %s", string(body))
var wtResp struct {
ID uuid.UUID `json:"id"`
Branch string `json:"branch"`
Status string `json:"status"`
}
require.NoError(t, json.Unmarshal(body, &wtResp))
require.Equal(t, "feat-x", wtResp.Branch)
require.Equal(t, "idle", wtResp.Status)
// ---- 6. acquire / release ----
resp, body = postJSON("/api/v1/worktrees/"+wtResp.ID.String()+"/acquire", nil)
require.Equalf(t, http.StatusOK, resp.StatusCode, "acquire: %s", string(body))
var afterAcq struct {
Status string `json:"status"`
}
require.NoError(t, json.Unmarshal(body, &afterAcq))
require.Equal(t, "active", afterAcq.Status)
resp, body = postJSON("/api/v1/worktrees/"+wtResp.ID.String()+"/release", nil)
require.Equalf(t, http.StatusOK, resp.StatusCode, "release: %s", string(body))
var afterRel struct {
Status string `json:"status"`
}
require.NoError(t, json.Unmarshal(body, &afterRel))
require.Equal(t, "idle", afterRel.Status)
// ---- 7. 同步 workspace(git fetch)→ 仍为 idle ----
resp, body = postJSON("/api/v1/workspaces/"+wsResp.ID.String()+"/sync", nil)
require.Equalf(t, http.StatusOK, resp.StatusCode, "sync: %s", string(body))
var afterSync struct {
SyncStatus string `json:"sync_status"`
}
require.NoError(t, json.Unmarshal(body, &afterSync))
require.Equal(t, "idle", afterSync.SyncStatus)
// ---- 8. 删除 worktree / workspace ----
require.Equal(t, http.StatusNoContent, deleteJSON("/api/v1/worktrees/"+wtResp.ID.String()).StatusCode)
require.Equal(t, http.StatusNoContent, deleteJSON("/api/v1/workspaces/"+wsResp.ID.String()).StatusCode)
}
+24
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
"time"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -34,6 +35,8 @@ type Config struct {
HTTP HTTPConfig `mapstructure:"http"` HTTP HTTPConfig `mapstructure:"http"`
DB DBConfig `mapstructure:"db"` DB DBConfig `mapstructure:"db"`
Bootstrap BootstrapConfig `mapstructure:"bootstrap"` Bootstrap BootstrapConfig `mapstructure:"bootstrap"`
Workspace Workspace `mapstructure:"workspace"`
Git Git `mapstructure:"git"`
} }
// HTTPConfig 描述 HTTP 服务监听参数。 // HTTPConfig 描述 HTTP 服务监听参数。
@@ -52,6 +55,21 @@ type BootstrapConfig struct {
AdminPassword string `mapstructure:"admin_password"` AdminPassword string `mapstructure:"admin_password"`
} }
// Workspace 控制 workspace 模块的磁盘与超时(数据目录、每个 git verb 的超时)。
// DataDir 是 workspace clone/worktree 落盘根目录;五个 timeout 直接灌进 git.Config。
type Workspace struct {
DataDir string `mapstructure:"data_dir"`
CloneTimeout time.Duration `mapstructure:"clone_timeout"`
FetchTimeout time.Duration `mapstructure:"fetch_timeout"`
PushTimeout time.Duration `mapstructure:"push_timeout"`
OpsTimeout time.Duration `mapstructure:"ops_timeout"`
}
// Git 控制 git CLI 二进制位置(默认 "git")。
type Git struct {
Binary string `mapstructure:"binary"`
}
// Load 从(按优先级递增)默认值 → 配置文件(如有)→ 环境变量 加载配置。 // Load 从(按优先级递增)默认值 → 配置文件(如有)→ 环境变量 加载配置。
// configFile 可为空字符串,仅用环境变量。 // configFile 可为空字符串,仅用环境变量。
func Load(configFile string) (*Config, error) { func Load(configFile string) (*Config, error) {
@@ -61,6 +79,12 @@ func Load(configFile string) (*Config, error) {
v.SetDefault("env", "development") v.SetDefault("env", "development")
v.SetDefault("data_dir", "./data") v.SetDefault("data_dir", "./data")
v.SetDefault("http.addr", ":8080") v.SetDefault("http.addr", ":8080")
v.SetDefault("workspace.data_dir", "./data")
v.SetDefault("workspace.clone_timeout", "30m")
v.SetDefault("workspace.fetch_timeout", "5m")
v.SetDefault("workspace.push_timeout", "5m")
v.SetDefault("workspace.ops_timeout", "60s")
v.SetDefault("git.binary", "git")
if configFile != "" { if configFile != "" {
v.SetConfigFile(configFile) v.SetConfigFile(configFile)
+15
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -50,6 +51,20 @@ func TestLoad_MasterKeyNotHex(t *testing.T) {
require.Contains(t, err.Error(), "invalid hex") require.Contains(t, err.Error(), "invalid hex")
} }
func TestLoad_WorkspaceDefaults(t *testing.T) {
// 验证 workspace.* 与 git.* 的默认值(含 string→time.Duration 自动解析)。
t.Setenv("APP_DB_DSN", "postgres://x")
t.Setenv("APP_MASTER_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
cfg, err := Load("")
require.NoError(t, err)
require.Equal(t, "./data", cfg.Workspace.DataDir)
require.Equal(t, 30*time.Minute, cfg.Workspace.CloneTimeout)
require.Equal(t, 5*time.Minute, cfg.Workspace.FetchTimeout)
require.Equal(t, 5*time.Minute, cfg.Workspace.PushTimeout)
require.Equal(t, 60*time.Second, cfg.Workspace.OpsTimeout)
require.Equal(t, "git", cfg.Git.Binary)
}
func TestMasterKey_Redaction(t *testing.T) { func TestMasterKey_Redaction(t *testing.T) {
k := MasterKey([]byte{0x01, 0x02, 0x03}) k := MasterKey([]byte{0x01, 0x02, 0x03})
require.Equal(t, "[REDACTED 32B]", k.String()) require.Equal(t, "[REDACTED 32B]", k.String())
+1
View File
@@ -121,6 +121,7 @@ type Caller struct {
type ProjectService interface { type ProjectService interface {
Create(ctx context.Context, c Caller, in CreateProjectInput) (*Project, error) Create(ctx context.Context, c Caller, in CreateProjectInput) (*Project, error)
Get(ctx context.Context, c Caller, slug string) (*Project, error) Get(ctx context.Context, c Caller, slug string) (*Project, error)
GetByID(ctx context.Context, c Caller, id uuid.UUID) (*Project, error)
List(ctx context.Context, c Caller, includeArchived bool) ([]*Project, error) List(ctx context.Context, c Caller, includeArchived bool) ([]*Project, error)
Update(ctx context.Context, c Caller, slug string, in UpdateProjectInput) (*Project, error) Update(ctx context.Context, c Caller, slug string, in UpdateProjectInput) (*Project, error)
Archive(ctx context.Context, c Caller, slug string) error Archive(ctx context.Context, c Caller, slug string) error
+13
View File
@@ -93,6 +93,19 @@ func (s *projectService) Get(ctx context.Context, c Caller, slug string) (*Proje
return p, nil return p, nil
} }
// GetByID 与 Get 等价但按 uuid 寻址,用于 workspace.ProjectAccess.ResolveByID。
// 鉴权矩阵与 Get 相同:private 仅 owner+admin,internal 任意登录用户。
func (s *projectService) GetByID(ctx context.Context, c Caller, id uuid.UUID) (*Project, error) {
p, err := s.repo.GetProjectByID(ctx, id)
if err != nil {
return nil, err
}
if err := assertReadable(p, c); err != nil {
return nil, err
}
return p, nil
}
func (s *projectService) List(ctx context.Context, c Caller, includeArchived bool) ([]*Project, error) { func (s *projectService) List(ctx context.Context, c Caller, includeArchived bool) ([]*Project, error) {
all, err := s.repo.ListProjects(ctx, includeArchived) all, err := s.repo.ListProjects(ctx, includeArchived)
if err != nil { if err != nil {
+24
View File
@@ -116,7 +116,9 @@ func (s *workspaceService) Create(ctx context.Context, c Caller, projectSlug str
} }
s.audit(ctx, &c.UserID, "workspace.create", "workspace", wsID.String(), map[string]any{"slug": in.Slug}) s.audit(ctx, &c.UserID, "workspace.create", "workspace", wsID.String(), map[string]any{"slug": in.Slug})
if s.clones != nil {
s.clones.Schedule(wsID) s.clones.Schedule(wsID)
}
return created, nil return created, nil
} }
@@ -304,6 +306,28 @@ func (s *workspaceService) encryptForUpsert(wsID uuid.UUID, in UpsertCredentialI
return cred, nil return cred, nil
} }
// CredentialAccessor 让 app 装配代码不重复实现凭据加载。
// CloneRunner 与 GitOpsService 都通过这个窄接口拿明文 git.Credential。
type CredentialAccessor interface {
LoadDecryptedCredential(ctx context.Context, wsID uuid.UUID) (*git.Credential, error)
}
// SchedulerSetter 让 app 装配代码在创建 cloneRunner 后回填到 service。
// NewWorkspaceService 阶段允许传 nil scheduler;构造完 cloneRunner 再 SetScheduler。
type SchedulerSetter interface {
SetScheduler(s CloneScheduler)
}
// LoadDecryptedCredential 暴露 loadDecryptedCredential 给 app 装配代码(CloneRunner、GitOps 共用)。
func (s *workspaceService) LoadDecryptedCredential(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) {
return s.loadDecryptedCredential(ctx, wsID)
}
// SetScheduler 在装配链路把后构造的 CloneRunner 回填到 service(NewWorkspaceService 阶段允许 nil)。
func (s *workspaceService) SetScheduler(sched CloneScheduler) {
s.clones = sched
}
func (s *workspaceService) loadDecryptedCredential(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) { func (s *workspaceService) loadDecryptedCredential(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) {
cred, err := s.repo.GetCredentialByWorkspace(ctx, wsID) cred, err := s.repo.GetCredentialByWorkspace(ctx, wsID)
if err != nil { if err != nil {
@@ -220,6 +220,55 @@ func TestWorkspaceService_Create_HappyPath(t *testing.T) {
require.True(t, ok) require.True(t, ok)
} }
func TestWorkspaceService_Create_NilScheduler_NoPanic(t *testing.T) {
// app 装配有"先建 service(scheduler=nil)→ 再用 service 构 cloneRunner → SetScheduler 回填"的环依赖。
// Create 必须在 scheduler==nil 时安全跳过 Schedule,否则首个 workspace 会 panic。
t.Parallel()
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
svc, repo := newSvc(t, pa, nil, &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)
_, ok := repo.creds[ws.ID]
require.True(t, ok)
}
func TestWorkspaceService_SetScheduler_BackfillSchedules(t *testing.T) {
// SetScheduler 把 nil scheduler 替换为真正实现,下一次 Create 应触发 Schedule。
t.Parallel()
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
svc, _ := newSvc(t, pa, nil, &fakeGit{})
sched := &fakeSched{}
// SchedulerSetter 接口契约:app.go 在构建 CloneRunner 后回填。
var setter SchedulerSetter = svc
setter.SetScheduler(sched)
caller := Caller{UserID: uuid.New()}
ws, err := svc.Create(context.Background(), caller, "p", CreateWorkspaceInput{
Slug: "ws2", Name: "W2", GitRemoteURL: "https://x", DefaultBranch: "main",
Credential: UpsertCredentialInput{Kind: CredKindNone},
})
require.NoError(t, err)
require.Len(t, sched.called, 1)
require.Equal(t, ws.ID, sched.called[0])
}
func TestWorkspaceService_LoadDecryptedCredential_PassThrough(t *testing.T) {
// CredentialAccessor 接口暴露内部 loadDecryptedCredential,CloneRunner / GitOpsService 复用。
t.Parallel()
pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true}
svc, _ := newSvc(t, pa, nil, &fakeGit{})
var acc CredentialAccessor = svc
cred, err := acc.LoadDecryptedCredential(context.Background(), uuid.New())
require.NoError(t, err)
require.NotNil(t, cred)
// 没有凭据行 → 返回 git.CredentialNone(loadDecryptedCredential 的兜底)。
require.Equal(t, git.CredentialNone, cred.Kind)
}
func TestWorkspaceService_Create_BadSlug(t *testing.T) { func TestWorkspaceService_Create_BadSlug(t *testing.T) {
t.Parallel() t.Parallel()
pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true} pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}