You've already forked agentic-coding-workflow
feat(app): wire workspace module + integration test for ws closed loop
This commit is contained in:
@@ -24,18 +24,23 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||
"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/git"
|
||||
"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/project"
|
||||
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/user"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
||||
)
|
||||
|
||||
// App 持有运行期所有需要被关停的资源。字段保持非导出,避免外部直接操作。
|
||||
@@ -103,6 +108,71 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
||||
notify.NewHandler(notifyRepo, 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 u, err := userSvc.Bootstrap(ctx, cfg.Bootstrap.AdminEmail, cfg.Bootstrap.AdminPassword); err != nil {
|
||||
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(窄接口)。
|
||||
// 复用 user.Service.Get:返回的 *User 直接读 IsAdmin。
|
||||
// workspace.AdminLookup 与 project.AdminLookup 同形(IsAdmin 同名同签名),
|
||||
// 因此本类型同时满足两者,可在两个 handler 复用。
|
||||
type userAdminAdapter struct{ svc user.Service }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user