From 9fc96b0a1ab938e56b853c39dc9a7fe62cb18e81 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Thu, 7 May 2026 14:11:47 +0800 Subject: [PATCH] feat(acp): SessionService skeleton + ResolveBranch (5 paths) + tests --- internal/acp/session_service.go | 116 +++++++++++++++++++++++++++ internal/acp/session_service_test.go | 106 ++++++++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 internal/acp/session_service.go create mode 100644 internal/acp/session_service_test.go diff --git a/internal/acp/session_service.go b/internal/acp/session_service.go new file mode 100644 index 0000000..fe12c01 --- /dev/null +++ b/internal/acp/session_service.go @@ -0,0 +1,116 @@ +// session_service.go 实现 SessionService 接口。Create 走完整事务(worktree +// acquire + supervisor.Spawn)的实现见 F3;本文件仅放骨架 + ResolveBranch +// pure helper。 +package acp + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/project" + "github.com/yan1h/agent-coding-workflow/internal/workspace" +) + +// SessionServiceConfig 是 service 的限流配置(从 AcpConfig 派生)。 +type SessionServiceConfig struct { + MaxActiveGlobal int + MaxActivePerUser int +} + +// ProjectAccess 是 acp 模块对 project 模块的窄接口;adapter 在 app.go 实现。 +type ProjectAccess interface { + GetProjectByWorkspace(ctx context.Context, wsID uuid.UUID) (*project.Project, error) + GetIssueByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Issue, error) + GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Requirement, error) +} + +type sessionService struct { + repo Repository + wsSvc workspace.WorkspaceService + wtSvc workspace.WorktreeService + wsRepo workspace.Repository + pa ProjectAccess + sup *Supervisor + audit audit.Recorder + cfg SessionServiceConfig +} + +// NewSessionService 构造 SessionService。 +func NewSessionService(repo Repository, wsSvc workspace.WorkspaceService, + wtSvc workspace.WorktreeService, wsRepo workspace.Repository, + pa ProjectAccess, sup *Supervisor, rec audit.Recorder, + cfg SessionServiceConfig) SessionService { + return &sessionService{ + repo: repo, wsSvc: wsSvc, wtSvc: wtSvc, wsRepo: wsRepo, + pa: pa, sup: sup, audit: rec, cfg: cfg, + } +} + +// ResolveBranch 是 spec §8.1 的纯函数实现:根据 CreateSessionInput 决定 +// (branch, isMainWorktree)。允许独立单测,不依赖 service 状态。 +// +// 规则(按优先级): +// 1. in.Branch 显式:直接用;isMain = (== ws.DefaultBranch) +// 2. in.IssueNumber:拉 issue,校验 workspace 关联,命名 "issue/-" +// 3. in.RequirementNumber:拉 requirement,校验 workspace,命名 "req/-" +// 4. 都没给:fallback "acp-auto/<8-hex>",isMain = false +func ResolveBranch(ctx context.Context, ws *workspace.Workspace, sessionID uuid.UUID, + in CreateSessionInput, projectSlug string, pa ProjectAccess) (string, bool, error) { + + if in.Branch != nil && *in.Branch != "" { + br := *in.Branch + return br, br == ws.DefaultBranch, nil + } + + if in.IssueNumber != nil { + iss, err := pa.GetIssueByNumber(ctx, ws.ProjectID, *in.IssueNumber) + if err != nil { + return "", false, err + } + if iss.WorkspaceID != nil && *iss.WorkspaceID != ws.ID { + return "", false, errs.New(errs.CodeInvalidInput, + fmt.Sprintf("issue #%d already linked to other workspace", *in.IssueNumber)) + } + return fmt.Sprintf("issue/%s-%d", projectSlug, iss.Number), false, nil + } + + if in.RequirementNumber != nil { + req, err := pa.GetRequirementByNumber(ctx, ws.ProjectID, *in.RequirementNumber) + if err != nil { + return "", false, err + } + if req.WorkspaceID != nil && *req.WorkspaceID != ws.ID { + return "", false, errs.New(errs.CodeInvalidInput, + fmt.Sprintf("requirement #%d already linked to other workspace", *in.RequirementNumber)) + } + return fmt.Sprintf("req/%s-%d", projectSlug, req.Number), false, nil + } + + short := strings.ReplaceAll(sessionID.String(), "-", "") + if len(short) > 8 { + short = short[:8] + } + return fmt.Sprintf("acp-auto/%s", short), false, nil +} + +// Create / Get / List / Terminate 在 F3 实现;先放占位让接口断言通过。 +func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionInput) (*Session, error) { + return nil, errs.New(errs.CodeInternal, "SessionService.Create not implemented (F3)") +} + +func (s *sessionService) Get(ctx context.Context, c Caller, id uuid.UUID) (*Session, error) { + return nil, errs.New(errs.CodeInternal, "SessionService.Get not implemented (F3)") +} + +func (s *sessionService) List(ctx context.Context, c Caller, all bool) ([]*Session, error) { + return nil, errs.New(errs.CodeInternal, "SessionService.List not implemented (F3)") +} + +func (s *sessionService) Terminate(ctx context.Context, c Caller, id uuid.UUID) error { + return errs.New(errs.CodeInternal, "SessionService.Terminate not implemented (F3)") +} diff --git a/internal/acp/session_service_test.go b/internal/acp/session_service_test.go new file mode 100644 index 0000000..439aed4 --- /dev/null +++ b/internal/acp/session_service_test.go @@ -0,0 +1,106 @@ +package acp_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp" + "github.com/yan1h/agent-coding-workflow/internal/project" + "github.com/yan1h/agent-coding-workflow/internal/workspace" +) + +// fakeProjectAccess 满足 acp.ProjectAccess 接口;测试时按需返回。 +type fakeProjectAccess struct { + pj *project.Project + issue *project.Issue + req *project.Requirement + err error +} + +func (f fakeProjectAccess) GetProjectByWorkspace(_ context.Context, _ uuid.UUID) (*project.Project, error) { + return f.pj, f.err +} +func (f fakeProjectAccess) GetIssueByNumber(_ context.Context, _ uuid.UUID, _ int) (*project.Issue, error) { + if f.issue == nil { + return nil, f.err + } + return f.issue, nil +} +func (f fakeProjectAccess) GetRequirementByNumber(_ context.Context, _ uuid.UUID, _ int) (*project.Requirement, error) { + if f.req == nil { + return nil, f.err + } + return f.req, nil +} + +func TestResolveBranch_Explicit(t *testing.T) { + t.Parallel() + ws := &workspace.Workspace{ID: uuid.New(), DefaultBranch: "main"} + br := "feat/x" + got, isMain, err := acp.ResolveBranch(context.Background(), ws, uuid.New(), + acp.CreateSessionInput{Branch: &br}, "demo", fakeProjectAccess{}) + require.NoError(t, err) + assert.Equal(t, "feat/x", got) + assert.False(t, isMain) +} + +func TestResolveBranch_ExplicitMain(t *testing.T) { + t.Parallel() + ws := &workspace.Workspace{ID: uuid.New(), DefaultBranch: "main"} + br := "main" + got, isMain, err := acp.ResolveBranch(context.Background(), ws, uuid.New(), + acp.CreateSessionInput{Branch: &br}, "demo", fakeProjectAccess{}) + require.NoError(t, err) + assert.Equal(t, "main", got) + assert.True(t, isMain) +} + +func TestResolveBranch_FromIssue(t *testing.T) { + t.Parallel() + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), DefaultBranch: "main"} + issueNum := 42 + pa := fakeProjectAccess{issue: &project.Issue{Number: 42, WorkspaceID: nil}} + got, isMain, err := acp.ResolveBranch(context.Background(), ws, uuid.New(), + acp.CreateSessionInput{IssueNumber: &issueNum}, "demo", pa) + require.NoError(t, err) + assert.Equal(t, "issue/demo-42", got) + assert.False(t, isMain) +} + +func TestResolveBranch_IssueLinkedToOtherWorkspace(t *testing.T) { + t.Parallel() + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), DefaultBranch: "main"} + other := uuid.New() + pa := fakeProjectAccess{issue: &project.Issue{Number: 42, WorkspaceID: &other}} + num := 42 + _, _, err := acp.ResolveBranch(context.Background(), ws, uuid.New(), + acp.CreateSessionInput{IssueNumber: &num}, "demo", pa) + require.Error(t, err) +} + +func TestResolveBranch_FromRequirement(t *testing.T) { + t.Parallel() + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), DefaultBranch: "main"} + num := 7 + pa := fakeProjectAccess{req: &project.Requirement{Number: 7, WorkspaceID: nil}} + got, isMain, err := acp.ResolveBranch(context.Background(), ws, uuid.New(), + acp.CreateSessionInput{RequirementNumber: &num}, "demo", pa) + require.NoError(t, err) + assert.Equal(t, "req/demo-7", got) + assert.False(t, isMain) +} + +func TestResolveBranch_AutoFallback(t *testing.T) { + t.Parallel() + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), DefaultBranch: "main"} + sid := uuid.New() + got, isMain, err := acp.ResolveBranch(context.Background(), ws, sid, + acp.CreateSessionInput{}, "demo", fakeProjectAccess{}) + require.NoError(t, err) + assert.Contains(t, got, "acp-auto/") + assert.False(t, isMain) +}