You've already forked agentic-coding-workflow
feat(workspace): domain types + pathing/validation
This commit is contained in:
@@ -0,0 +1,192 @@
|
|||||||
|
// Package workspace 承载 Workspace / Worktree / GitOps 三个 service 的领域类型与
|
||||||
|
// 接口契约。沿 PM 模块(internal/project)的"单包多 service"风格:聚合在 schema
|
||||||
|
// 上紧耦合,分包反而要互相 import。
|
||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncStatus 与 DB CHECK 约束完全一致。
|
||||||
|
type SyncStatus string
|
||||||
|
|
||||||
|
// SyncStatus 枚举值,与 migrations/0003 的 CHECK 约束完全一致。
|
||||||
|
const (
|
||||||
|
SyncStatusCloning SyncStatus = "cloning"
|
||||||
|
SyncStatusIdle SyncStatus = "idle"
|
||||||
|
SyncStatusSyncing SyncStatus = "syncing"
|
||||||
|
SyncStatusError SyncStatus = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsValid 报告 s 是否在枚举集合内。
|
||||||
|
func (s SyncStatus) IsValid() bool {
|
||||||
|
switch s {
|
||||||
|
case SyncStatusCloning, SyncStatusIdle, SyncStatusSyncing, SyncStatusError:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorktreeStatus 与 DB CHECK 约束完全一致。
|
||||||
|
type WorktreeStatus string
|
||||||
|
|
||||||
|
// WorktreeStatus 枚举值,与 migrations/0003 的 CHECK 约束完全一致。
|
||||||
|
const (
|
||||||
|
WorktreeStatusIdle WorktreeStatus = "idle"
|
||||||
|
WorktreeStatusActive WorktreeStatus = "active"
|
||||||
|
WorktreeStatusPruning WorktreeStatus = "pruning"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CredentialKind 与 DB CHECK 约束完全一致;与 git.CredentialKind 同值,但分开
|
||||||
|
// 定义避免业务包对 infra/git 类型的强依赖。
|
||||||
|
type CredentialKind string
|
||||||
|
|
||||||
|
// CredentialKind 枚举值,与 migrations/0003 的 CHECK 约束完全一致。
|
||||||
|
const (
|
||||||
|
CredKindPAT CredentialKind = "pat"
|
||||||
|
CredKindSSHKey CredentialKind = "ssh_key"
|
||||||
|
CredKindNone CredentialKind = "none"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsValid 报告 k 是否在枚举集合内。
|
||||||
|
func (k CredentialKind) IsValid() bool {
|
||||||
|
return k == CredKindPAT || k == CredKindSSHKey || k == CredKindNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workspace 是顶层聚合根。
|
||||||
|
type Workspace struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
ProjectID uuid.UUID
|
||||||
|
Slug string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
GitRemoteURL string
|
||||||
|
DefaultBranch string
|
||||||
|
MainPath string
|
||||||
|
SyncStatus SyncStatus
|
||||||
|
LastSyncedAt *time.Time
|
||||||
|
LastSyncError string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worktree 表示一个支线 worktree(不含 main_path)。
|
||||||
|
type Worktree struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
WorkspaceID uuid.UUID
|
||||||
|
Branch string
|
||||||
|
Path string
|
||||||
|
Status WorktreeStatus
|
||||||
|
ActiveHolder string // "user:<uid>" / "session:<sid>" / 空
|
||||||
|
AcquiredAt *time.Time
|
||||||
|
LastUsedAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credential 是 git_credentials 表的领域投影。Secret 仅在创建/更新时有值,
|
||||||
|
// 读取路径上 Service 永远不返回明文 secret。Fingerprint 是给前端展示的脱敏串。
|
||||||
|
type Credential struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
WorkspaceID uuid.UUID
|
||||||
|
Kind CredentialKind
|
||||||
|
Username string
|
||||||
|
Secret []byte // 写路径填明文;读路径恒为 nil
|
||||||
|
Fingerprint string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caller 表示发起请求的用户上下文。Service 据此判定鉴权与 holder 字符串。
|
||||||
|
type Caller struct {
|
||||||
|
UserID uuid.UUID
|
||||||
|
IsAdmin bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holder 把 Caller 折成 acquire_holder 字符串。Plan #4 的 ACP supervisor 会用
|
||||||
|
// "session:<sid>" 形式调相同接口。
|
||||||
|
func (c Caller) Holder() string {
|
||||||
|
return "user:" + c.UserID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Service 接口 =====
|
||||||
|
|
||||||
|
// WorkspaceService 暴露 Workspace 聚合根的应用服务方法。
|
||||||
|
//
|
||||||
|
//nolint:revive // 名字带聚合前缀比 "Service" 更准确:包内有 3 个并列服务接口
|
||||||
|
type WorkspaceService interface {
|
||||||
|
Create(ctx context.Context, c Caller, projectSlug string, in CreateWorkspaceInput) (*Workspace, error)
|
||||||
|
Get(ctx context.Context, c Caller, wsID uuid.UUID) (*Workspace, error)
|
||||||
|
List(ctx context.Context, c Caller, projectSlug string) ([]*Workspace, error)
|
||||||
|
Update(ctx context.Context, c Caller, wsID uuid.UUID, in UpdateWorkspaceInput) (*Workspace, error)
|
||||||
|
Delete(ctx context.Context, c Caller, wsID uuid.UUID) error
|
||||||
|
Sync(ctx context.Context, c Caller, wsID uuid.UUID) (*Workspace, error)
|
||||||
|
|
||||||
|
UpsertCredential(ctx context.Context, c Caller, wsID uuid.UUID, in UpsertCredentialInput) (*Credential, error)
|
||||||
|
GetCredentialMeta(ctx context.Context, c Caller, wsID uuid.UUID) (*Credential, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorktreeService 暴露支线 worktree 的应用服务方法。
|
||||||
|
type WorktreeService interface {
|
||||||
|
Create(ctx context.Context, c Caller, wsID uuid.UUID, in CreateWorktreeInput) (*Worktree, error)
|
||||||
|
List(ctx context.Context, c Caller, wsID uuid.UUID) ([]*Worktree, error)
|
||||||
|
Delete(ctx context.Context, c Caller, wtID uuid.UUID) error
|
||||||
|
Acquire(ctx context.Context, c Caller, wtID uuid.UUID) (*Worktree, error)
|
||||||
|
Release(ctx context.Context, c Caller, wtID uuid.UUID) (*Worktree, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitOpsService 暴露 status/commit/push 操作。target 既可以是 Workspace(main_path)
|
||||||
|
// 也可以是 Worktree。
|
||||||
|
type GitOpsService interface {
|
||||||
|
StatusOnMain(ctx context.Context, c Caller, wsID uuid.UUID) ([]git.FileStatus, error)
|
||||||
|
CommitOnMain(ctx context.Context, c Caller, wsID uuid.UUID, in CommitInput) (string, error)
|
||||||
|
PushOnMain(ctx context.Context, c Caller, wsID uuid.UUID) error
|
||||||
|
|
||||||
|
StatusOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID) ([]git.FileStatus, error)
|
||||||
|
CommitOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID, in CommitInput) (string, error)
|
||||||
|
PushOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Input DTO =====
|
||||||
|
|
||||||
|
// CreateWorkspaceInput 携带创建 Workspace 与其凭据的字段。
|
||||||
|
type CreateWorkspaceInput struct {
|
||||||
|
Slug string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
GitRemoteURL string
|
||||||
|
DefaultBranch string
|
||||||
|
Credential UpsertCredentialInput
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateWorkspaceInput 是 patch 输入:nil = 保持原值。
|
||||||
|
type UpdateWorkspaceInput struct {
|
||||||
|
Name *string
|
||||||
|
Description *string
|
||||||
|
DefaultBranch *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertCredentialInput 创建或替换 git 凭据。Secret 是明文(PAT token / SSH 私钥 PEM)。
|
||||||
|
type UpsertCredentialInput struct {
|
||||||
|
Kind CredentialKind
|
||||||
|
Username string // PAT 时填
|
||||||
|
Secret []byte // PAT/SSH 时填;None 时为空
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWorktreeInput 创建支线 worktree。BaseBranch 留空时用 workspace.DefaultBranch。
|
||||||
|
type CreateWorktreeInput struct {
|
||||||
|
Branch string
|
||||||
|
BaseBranch string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitInput 提交描述。AddAll 留 false 等价于"已经手动 git add",默认 service
|
||||||
|
// 调用方传 true(与 4.4 端点 default 对齐)。
|
||||||
|
type CommitInput struct {
|
||||||
|
Message string
|
||||||
|
Push bool
|
||||||
|
AddAll bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 输入校验正则。改动需要同步更新 spec §3.6 与 dto.go 的错误消息。
|
||||||
|
var (
|
||||||
|
slugRe = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$`)
|
||||||
|
branchRe = regexp.MustCompile(`^[A-Za-z0-9._/-]{1,200}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateSlug 报告 slug 是否合法。
|
||||||
|
func ValidateSlug(slug string) error {
|
||||||
|
if !slugRe.MatchString(slug) {
|
||||||
|
return errors.New("slug must be lowercase alnum + '-' (1..32 chars), no leading/trailing dash")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateBranch 报告 branch 名是否合法(防止被 git 当成 flag)。
|
||||||
|
func ValidateBranch(b string) error {
|
||||||
|
if !branchRe.MatchString(b) {
|
||||||
|
return errors.New("branch must match [A-Za-z0-9._/-]{1,200}")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(b, "-") || strings.HasPrefix(b, "+") || strings.HasPrefix(b, "/") {
|
||||||
|
return errors.New("branch must not start with '-', '+', or '/'")
|
||||||
|
}
|
||||||
|
if strings.Contains(b, "..") {
|
||||||
|
return errors.New("branch must not contain '..'")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRemoteURL 检查 scheme 白名单。
|
||||||
|
func ValidateRemoteURL(u string) error {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(u, "https://"),
|
||||||
|
strings.HasPrefix(u, "http://"),
|
||||||
|
strings.HasPrefix(u, "ssh://"),
|
||||||
|
strings.HasPrefix(u, "git@"):
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("git_remote_url must be https/http/ssh/git@ scheme")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MainPath 计算 workspace.main_path 绝对路径。dataDir 是配置中的 ${DATA_DIR}。
|
||||||
|
func MainPath(dataDir string, wsID uuid.UUID) string {
|
||||||
|
return filepath.Join(dataDir, "workspaces", wsID.String(), "main")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorktreePath 计算支线 worktree 绝对路径。branch slug 化后嵌入路径。
|
||||||
|
func WorktreePath(dataDir string, wsID uuid.UUID, branch string) string {
|
||||||
|
return filepath.Join(dataDir, "workspaces", wsID.String(), ".worktrees", branchSlug(branch))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TmpDir 是 git CLI 凭据/known_hosts 临时文件根目录。启动期需清空。
|
||||||
|
func TmpDir(dataDir string) string {
|
||||||
|
return filepath.Join(dataDir, "tmp")
|
||||||
|
}
|
||||||
|
|
||||||
|
// branchSlug 把 branch 名转成可作为目录名的字符串。
|
||||||
|
//
|
||||||
|
// 规则:把 '/' 替换为 '_';其他非法字符保留(branchRe 已限制)。
|
||||||
|
func branchSlug(b string) string {
|
||||||
|
return strings.ReplaceAll(b, "/", "_")
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package workspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateSlug(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
good := []string{"a", "ab", "ws-1", "abc-def", "x9"}
|
||||||
|
bad := []string{"", "-x", "x-", "Ab", "a--b is invalid? let's check: a--b"}
|
||||||
|
for _, s := range good {
|
||||||
|
require.NoError(t, ValidateSlug(s), s)
|
||||||
|
}
|
||||||
|
for _, s := range bad {
|
||||||
|
// "a--b" 实际是合法的(regex 允许内部连字符);剔除该用例
|
||||||
|
if s == "a--b is invalid? let's check: a--b" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
require.Error(t, ValidateSlug(s), s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBranch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
require.NoError(t, ValidateBranch("main"))
|
||||||
|
require.NoError(t, ValidateBranch("feat/foo-bar"))
|
||||||
|
require.NoError(t, ValidateBranch("release.1"))
|
||||||
|
require.Error(t, ValidateBranch("-x"))
|
||||||
|
require.Error(t, ValidateBranch("/x"))
|
||||||
|
require.Error(t, ValidateBranch("a..b"))
|
||||||
|
require.Error(t, ValidateBranch(""))
|
||||||
|
require.Error(t, ValidateBranch(strings.Repeat("a", 201)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRemoteURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
require.NoError(t, ValidateRemoteURL("https://github.com/x/y.git"))
|
||||||
|
require.NoError(t, ValidateRemoteURL("git@github.com:x/y.git"))
|
||||||
|
require.NoError(t, ValidateRemoteURL("ssh://git@host/x.git"))
|
||||||
|
require.Error(t, ValidateRemoteURL("file:///etc/passwd"))
|
||||||
|
require.Error(t, ValidateRemoteURL("ftp://x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaths(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
id := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||||
|
// 用 filepath.Base 做断言以跨平台兼容(Windows 下 filepath.Join 返回反斜杠)。
|
||||||
|
require.Equal(t, "main", filepath.Base(MainPath("/data", id)))
|
||||||
|
require.True(t, strings.Contains(WorktreePath("/data", id, "feat/x"), "feat_x"))
|
||||||
|
require.Equal(t, "tmp", filepath.Base(TmpDir("/data")))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user