From e5800ef5dc7bc5a452ce1dcf7457d565c4145883 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 1 May 2026 22:02:11 +0800 Subject: [PATCH] feat(workspace): domain types + pathing/validation --- internal/workspace/domain.go | 192 +++++++++++++++++++++++++++++ internal/workspace/pathing.go | 72 +++++++++++ internal/workspace/pathing_test.go | 56 +++++++++ 3 files changed, 320 insertions(+) create mode 100644 internal/workspace/domain.go create mode 100644 internal/workspace/pathing.go create mode 100644 internal/workspace/pathing_test.go diff --git a/internal/workspace/domain.go b/internal/workspace/domain.go new file mode 100644 index 0000000..44d7187 --- /dev/null +++ b/internal/workspace/domain.go @@ -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:" / "session:" / 空 + 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:" 形式调相同接口。 +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 +} diff --git a/internal/workspace/pathing.go b/internal/workspace/pathing.go new file mode 100644 index 0000000..2b14d5e --- /dev/null +++ b/internal/workspace/pathing.go @@ -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, "/", "_") +} diff --git a/internal/workspace/pathing_test.go b/internal/workspace/pathing_test.go new file mode 100644 index 0000000..16eaaf4 --- /dev/null +++ b/internal/workspace/pathing_test.go @@ -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"))) +}