You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
// projectACL(app 层中央 ACL)必须与 project_service 的 assertReadable/assertWritable
|
||||
// 语义一致——两侧任一漂移都会让 workspace/ACP/chat 作用域与 project 鉴权分叉
|
||||
// (autonomy roadmap §11)。本白盒测试直接覆盖 projectACL 的角色叠加矩阵。
|
||||
func TestProjectACL_RoleMatrix(t *testing.T) {
|
||||
owner := uuid.New()
|
||||
stranger := uuid.New()
|
||||
|
||||
private := &project.Project{ID: uuid.New(), OwnerID: owner, Visibility: project.VisibilityPrivate}
|
||||
internal := &project.Project{ID: uuid.New(), OwnerID: owner, Visibility: project.VisibilityInternal}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
pj *project.Project
|
||||
caller uuid.UUID
|
||||
isAdmin bool
|
||||
role project.Role
|
||||
wantRead, wantWrt bool
|
||||
}{
|
||||
{"owner_private", private, owner, false, project.RoleAdmin, true, true},
|
||||
{"global_admin_private", private, stranger, true, project.RoleNone, true, true},
|
||||
{"member_private_write", private, stranger, false, project.RoleMember, true, true},
|
||||
{"admin_role_private_write", private, stranger, false, project.RoleAdmin, true, true},
|
||||
{"viewer_private_read_only", private, stranger, false, project.RoleViewer, true, false},
|
||||
{"nonmember_private_denied", private, stranger, false, project.RoleNone, false, false},
|
||||
{"nonmember_internal_read", internal, stranger, false, project.RoleNone, true, false},
|
||||
{"viewer_internal_read", internal, stranger, false, project.RoleViewer, true, false},
|
||||
{"member_internal_write", internal, stranger, false, project.RoleMember, true, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotRead, gotWrite := projectACL(tc.pj, tc.caller, tc.isAdmin, tc.role)
|
||||
if gotRead != tc.wantRead || gotWrite != tc.wantWrt {
|
||||
t.Fatalf("projectACL(%s)=read:%v,write:%v want read:%v,write:%v",
|
||||
tc.name, gotRead, gotWrite, tc.wantRead, tc.wantWrt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func newACPIntegrationSuite(t *testing.T) *acpIntegrationSuite {
|
||||
paStub := &acpStubProjectAccess{}
|
||||
|
||||
sessSvc := acp.NewSessionService(
|
||||
acpRepo, wsStub, nil, nil, paStub, sup, auditRec,
|
||||
acpRepo, wsStub, nil, nil, paStub, nil, nil, sup, auditRec,
|
||||
acp.SessionServiceConfig{MaxActiveGlobal: 20, MaxActivePerUser: 3},
|
||||
nil, // mcpTokens — not exercised by integration tests
|
||||
)
|
||||
|
||||
+914
-41
File diff suppressed because it is too large
Load Diff
@@ -156,7 +156,7 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
|
||||
|
||||
projectRepo := project.NewPostgresRepository(pool)
|
||||
projectSvc := project.NewProjectService(projectRepo, auditRec)
|
||||
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
|
||||
requirementSvc := project.NewRequirementService(projectRepo, auditRec, nil, nil)
|
||||
issueSvc := project.NewIssueService(projectRepo, auditRec)
|
||||
artifactSvc := project.NewArtifactService(projectRepo, auditRec)
|
||||
|
||||
@@ -324,7 +324,7 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
|
||||
|
||||
projectRepo := project.NewPostgresRepository(pool)
|
||||
projectSvc := project.NewProjectService(projectRepo, auditRec)
|
||||
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
|
||||
requirementSvc := project.NewRequirementService(projectRepo, auditRec, nil, nil)
|
||||
issueSvc := project.NewIssueService(projectRepo, auditRec)
|
||||
artifactSvc := project.NewArtifactService(projectRepo, auditRec)
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ func newMCPIntegrationSuite(t *testing.T) *mcpIntegrationSuite {
|
||||
|
||||
projectRepo := project.NewPostgresRepository(pool)
|
||||
projectSvc := project.NewProjectService(projectRepo, auditWrapped)
|
||||
requirementSvc := project.NewRequirementService(projectRepo, auditWrapped)
|
||||
requirementSvc := project.NewRequirementService(projectRepo, auditWrapped, nil, nil)
|
||||
issueSvc := project.NewIssueService(projectRepo, auditWrapped)
|
||||
|
||||
// --- Bootstrap users ---
|
||||
@@ -268,7 +268,7 @@ func (s *mcpIntegrationSuite) callToolViaMemory(
|
||||
deps := mcp.ServerDeps{
|
||||
Caller: callerResolver,
|
||||
Projects: s.projectSvc,
|
||||
Reqs: project.NewRequirementService(project.NewPostgresRepository(s.pool), mcp.AuditWrap(s.auditRec)),
|
||||
Reqs: project.NewRequirementService(project.NewPostgresRepository(s.pool), mcp.AuditWrap(s.auditRec), nil, nil),
|
||||
Issues: s.issueSvc,
|
||||
// Workspaces/Templates/Messages/Conversations left nil — K3 tests don't call those tools.
|
||||
}
|
||||
@@ -928,7 +928,7 @@ func (s *mcpIntegrationSuite) testPromptsListFromTemplates(t *testing.T) {
|
||||
deps := mcp.ServerDeps{
|
||||
Caller: callerResolver,
|
||||
Projects: s.projectSvc,
|
||||
Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec)),
|
||||
Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec), nil, nil),
|
||||
Issues: s.issueSvc,
|
||||
Templates: tplSvc,
|
||||
}
|
||||
@@ -970,7 +970,7 @@ func (s *mcpIntegrationSuite) testResourceReadNotFound(t *testing.T) {
|
||||
deps := mcp.ServerDeps{
|
||||
Caller: callerResolver,
|
||||
Projects: s.projectSvc,
|
||||
Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec)),
|
||||
Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec), nil, nil),
|
||||
Issues: s.issueSvc,
|
||||
}
|
||||
srv := mcp.NewMCPServer(deps)
|
||||
@@ -1004,7 +1004,7 @@ func (s *mcpIntegrationSuite) testResourceListScopeFilter(t *testing.T) {
|
||||
deps := mcp.ServerDeps{
|
||||
Caller: callerResolver,
|
||||
Projects: s.projectSvc,
|
||||
Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec)),
|
||||
Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec), nil, nil),
|
||||
Issues: s.issueSvc,
|
||||
}
|
||||
srv := mcp.NewMCPServer(deps)
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/acp"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/orchestrator"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
||||
)
|
||||
|
||||
// orchPMAdapter 把 project 的 Repository + RequirementService 适配为 orchestrator.PMAccess。
|
||||
// 读走 Repository(无需 Caller 鉴权——编排器内部以 run.owner 为权威身份);ChangePhase
|
||||
// 走 RequirementService 以复用阶段网关/审计。
|
||||
type orchPMAdapter struct {
|
||||
repo project.Repository
|
||||
reqs project.RequirementService
|
||||
adminLookup userAdminAdapter
|
||||
}
|
||||
|
||||
func (a orchPMAdapter) GetProjectByID(ctx context.Context, id uuid.UUID) (*project.Project, error) {
|
||||
return a.repo.GetProjectByID(ctx, id)
|
||||
}
|
||||
|
||||
func (a orchPMAdapter) GetProjectBySlug(ctx context.Context, slug string) (*project.Project, error) {
|
||||
return a.repo.GetProjectBySlug(ctx, slug)
|
||||
}
|
||||
|
||||
func (a orchPMAdapter) GetRequirementByID(ctx context.Context, id uuid.UUID) (*project.Requirement, error) {
|
||||
return a.repo.GetRequirementByID(ctx, id)
|
||||
}
|
||||
|
||||
func (a orchPMAdapter) GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Requirement, error) {
|
||||
return a.repo.GetRequirementByNumber(ctx, projectID, number)
|
||||
}
|
||||
|
||||
// ListLatestArtifacts 取某需求各阶段最新产物(每阶段最大版本),供 BuildPrompt 注入。
|
||||
func (a orchPMAdapter) ListLatestArtifacts(ctx context.Context, requirementID uuid.UUID) ([]*project.Artifact, error) {
|
||||
all, err := a.repo.ListArtifactsByRequirement(ctx, requirementID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
latest := map[project.Phase]*project.Artifact{}
|
||||
for _, art := range all {
|
||||
if art == nil {
|
||||
continue
|
||||
}
|
||||
if cur, ok := latest[art.Phase]; !ok || art.Version > cur.Version {
|
||||
latest[art.Phase] = art
|
||||
}
|
||||
}
|
||||
out := make([]*project.Artifact, 0, len(latest))
|
||||
for _, ph := range project.AllPhases {
|
||||
if art, ok := latest[ph]; ok {
|
||||
out = append(out, art)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ChangeRequirementPhase 以 run.owner 身份推进需求阶段(经 RequirementService 复用网关/审计)。
|
||||
func (a orchPMAdapter) ChangeRequirementPhase(ctx context.Context, ownerID uuid.UUID, isAdmin bool, projectSlug string, number int, to project.Phase) error {
|
||||
_, err := a.reqs.ChangePhase(ctx, project.Caller{UserID: ownerID, IsAdmin: isAdmin}, projectSlug, number, to)
|
||||
return err
|
||||
}
|
||||
|
||||
// orchACLAdapter 把 project ACL 适配为 orchestrator.ProjectACL(owner+admin 可写)。
|
||||
type orchACLAdapter struct {
|
||||
pa projectAccessAdapter
|
||||
}
|
||||
|
||||
func (a orchACLAdapter) CanRead(ctx context.Context, userID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) {
|
||||
canRead, _, err := a.pa.ResolveByID(ctx, userID, isAdmin, projectID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return canRead, nil
|
||||
}
|
||||
|
||||
func (a orchACLAdapter) CanWrite(ctx context.Context, userID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) {
|
||||
_, canWrite, err := a.pa.ResolveByID(ctx, userID, isAdmin, projectID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return canWrite, nil
|
||||
}
|
||||
|
||||
// orchAuditAdapter 把 audit.Recorder 适配为 orchestrator.auditRecorder(窄 record 方法)。
|
||||
// 注:orchestrator.auditRecorder 是非导出接口,但 app 在同模块外通过结构方法匹配满足。
|
||||
type orchAuditAdapter struct {
|
||||
rec audit.Recorder
|
||||
}
|
||||
|
||||
func (a orchAuditAdapter) Record(ctx context.Context, userID uuid.UUID, action, targetID string, meta map[string]any) {
|
||||
if a.rec == nil {
|
||||
return
|
||||
}
|
||||
uid := userID
|
||||
_ = a.rec.Record(ctx, audit.Entry{
|
||||
UserID: &uid, Action: action, TargetType: "orchestrator_run", TargetID: targetID, Metadata: meta,
|
||||
})
|
||||
}
|
||||
|
||||
// orchSessionTermAdapter 把 acp.SessionService 适配为 orchestrator.sessionTerminator。
|
||||
type orchSessionTermAdapter struct {
|
||||
sess acp.SessionService
|
||||
}
|
||||
|
||||
func (a orchSessionTermAdapter) TerminateSession(ctx context.Context, ownerID uuid.UUID, isAdmin bool, sessionID uuid.UUID) error {
|
||||
return a.sess.Terminate(ctx, acp.Caller{UserID: ownerID, IsAdmin: isAdmin}, sessionID)
|
||||
}
|
||||
|
||||
// orchGateArtifactAdapter 把 project.Repository 适配为 orchestrator.GateArtifactAccess。
|
||||
type orchGateArtifactAdapter struct {
|
||||
repo project.Repository
|
||||
}
|
||||
|
||||
func (a orchGateArtifactAdapter) ListArtifacts(ctx context.Context, projectID, requirementID uuid.UUID, phase project.Phase) ([]*project.Artifact, error) {
|
||||
return a.repo.ListArtifactsByRequirement(ctx, requirementID, phase)
|
||||
}
|
||||
|
||||
// orchGateIssueAdapter 把 project.Repository 适配为 orchestrator.GateIssueAccess。
|
||||
type orchGateIssueAdapter struct {
|
||||
repo project.Repository
|
||||
}
|
||||
|
||||
func (a orchGateIssueAdapter) CountOpenIssues(ctx context.Context, requirementID uuid.UUID) (int, error) {
|
||||
issues, err := a.repo.ListIssuesByRequirement(ctx, requirementID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
for _, iss := range issues {
|
||||
if iss != nil && iss.Status == project.StatusOpen {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// orchACPLookupAdapter 把 acp.Repository 适配为 orchestrator.ACPSessionLookup。
|
||||
type orchACPLookupAdapter struct {
|
||||
repo acp.Repository
|
||||
}
|
||||
|
||||
func (a orchACPLookupAdapter) GetSessionByStepID(ctx context.Context, stepID uuid.UUID) (*acp.Session, error) {
|
||||
return a.repo.GetSessionByStepID(ctx, stepID)
|
||||
}
|
||||
|
||||
func (a orchACPLookupAdapter) GetCrashedSessionForResume(ctx context.Context, stepID uuid.UUID) (*acp.Session, error) {
|
||||
return a.repo.GetCrashedSessionForResume(ctx, stepID)
|
||||
}
|
||||
|
||||
// 确保 acp.SessionService 同时满足 orchestrator.ACPSessions(Create)。
|
||||
var _ orchestrator.ACPSessions = (acp.SessionService)(nil)
|
||||
|
||||
// ===== 任务分解并行调度器适配器(autonomy roadmap §10)=====
|
||||
|
||||
// schedulerReadySourceAdapter 把 project.Repository 适配为 orchestrator.ReadyTaskSource。
|
||||
type schedulerReadySourceAdapter struct {
|
||||
repo project.Repository
|
||||
}
|
||||
|
||||
func (a schedulerReadySourceAdapter) ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error) {
|
||||
return a.repo.ListSchedulableProjects(ctx)
|
||||
}
|
||||
|
||||
func (a schedulerReadySourceAdapter) ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*project.Issue, error) {
|
||||
return a.repo.ListReadyLeafSubtasks(ctx, projectID, limit)
|
||||
}
|
||||
|
||||
func (a schedulerReadySourceAdapter) CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error) {
|
||||
return a.repo.CountActiveSessionsForProject(ctx, projectID)
|
||||
}
|
||||
|
||||
var _ orchestrator.ReadyTaskSource = schedulerReadySourceAdapter{}
|
||||
|
||||
// schedulerSessionStarterAdapter 为就绪子任务以特权身份(项目 owner)创建 ACP session。
|
||||
// 解析 workspace(issue.WorkspaceID 优先,否则取项目首个 workspace)与 agent-kind
|
||||
// (implementing 阶段默认),把 IssueNumber 注入使 SessionService 绑定 issue/<slug>-N worktree。
|
||||
type schedulerSessionStarterAdapter struct {
|
||||
sess acp.SessionService
|
||||
projectRepo project.Repository
|
||||
wsRepo workspace.Repository
|
||||
defaultAgentKind func() (uuid.UUID, bool) // 解析 implementing 阶段默认 agent-kind
|
||||
}
|
||||
|
||||
func (a schedulerSessionStarterAdapter) StartForIssue(ctx context.Context, iss *project.Issue) (uuid.UUID, error) {
|
||||
proj, err := a.projectRepo.GetProjectByID(ctx, iss.ProjectID)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
// 解析 workspace:issue 显式关联优先,否则取项目首个 workspace。
|
||||
var wsID uuid.UUID
|
||||
if iss.WorkspaceID != nil {
|
||||
wsID = *iss.WorkspaceID
|
||||
} else {
|
||||
list, lerr := a.wsRepo.ListWorkspacesByProject(ctx, iss.ProjectID)
|
||||
if lerr != nil {
|
||||
return uuid.Nil, lerr
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return uuid.Nil, errs.New(errs.CodeInvalidInput, "project 无可用 workspace,无法为子任务派发 session")
|
||||
}
|
||||
wsID = list[0].ID
|
||||
}
|
||||
akID, ok := a.defaultAgentKind()
|
||||
if !ok {
|
||||
return uuid.Nil, errs.New(errs.CodeInvalidInput, "未配置 implementing 阶段默认 agent-kind,无法派发子任务 session")
|
||||
}
|
||||
num := iss.Number
|
||||
owner := acp.Caller{UserID: proj.OwnerID, IsAdmin: false}
|
||||
created, err := a.sess.Create(ctx, owner, acp.CreateSessionInput{
|
||||
WorkspaceID: wsID,
|
||||
AgentKindID: akID,
|
||||
IssueNumber: &num,
|
||||
})
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
var _ orchestrator.SessionStarter = schedulerSessionStarterAdapter{}
|
||||
|
||||
// schedulerNotifyAdapter 把 notify.Dispatcher 适配为 orchestrator.SchedulerNotifier。
|
||||
// 通知发给项目 owner(best-effort,失败不阻塞调度)。
|
||||
type schedulerNotifyAdapter struct {
|
||||
dispatcher *notify.Dispatcher
|
||||
projectRepo project.Repository
|
||||
}
|
||||
|
||||
func (a schedulerNotifyAdapter) ownerOf(ctx context.Context, projectID uuid.UUID) (uuid.UUID, bool) {
|
||||
proj, err := a.projectRepo.GetProjectByID(ctx, projectID)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return proj.OwnerID, true
|
||||
}
|
||||
|
||||
func (a schedulerNotifyAdapter) NotifyDispatched(ctx context.Context, iss *project.Issue, sessionID uuid.UUID) {
|
||||
if a.dispatcher == nil {
|
||||
return
|
||||
}
|
||||
owner, ok := a.ownerOf(ctx, iss.ProjectID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = a.dispatcher.Dispatch(ctx, notify.Message{
|
||||
UserID: owner,
|
||||
Topic: "orchestrator.subtask_dispatched",
|
||||
Severity: notify.SeverityInfo,
|
||||
Title: "子任务已派发",
|
||||
Body: "子任务 #" + itoa(iss.Number) + " 已派发到并行 ACP session",
|
||||
Metadata: map[string]any{"issue_number": iss.Number, "session_id": sessionID.String()},
|
||||
})
|
||||
}
|
||||
|
||||
func (a schedulerNotifyAdapter) NotifyQuotaBlocked(ctx context.Context, iss *project.Issue) {
|
||||
if a.dispatcher == nil {
|
||||
return
|
||||
}
|
||||
owner, ok := a.ownerOf(ctx, iss.ProjectID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = a.dispatcher.Dispatch(ctx, notify.Message{
|
||||
UserID: owner,
|
||||
Topic: "orchestrator.subtask_quota_blocked",
|
||||
Severity: notify.SeverityWarning,
|
||||
Title: "子任务派发受配额限制",
|
||||
Body: "子任务 #" + itoa(iss.Number) + " 因会话配额暂缓派发,下一轮重试",
|
||||
Metadata: map[string]any{"issue_number": iss.Number},
|
||||
})
|
||||
}
|
||||
|
||||
var _ orchestrator.SchedulerNotifier = schedulerNotifyAdapter{}
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
Reference in New Issue
Block a user