You've already forked agentic-coding-workflow
feat(acp): SessionService skeleton + ResolveBranch (5 paths) + tests
This commit is contained in:
@@ -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/<slug>-<num>"
|
||||||
|
// 3. in.RequirementNumber:拉 requirement,校验 workspace,命名 "req/<slug>-<num>"
|
||||||
|
// 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)")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user