feat(projectinstance): classify optional duties without lowering required gates

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-14 06:53:21 +00:00
co-authored by Cursor
parent de66558736
commit ea97a658c6
6 changed files with 262 additions and 37 deletions
+156
View File
@@ -0,0 +1,156 @@
package projectinstance
import (
"fmt"
"chat-agent-system/internal/workflowdef"
)
type DutyMode string
const (
DutyModeNone DutyMode = "none"
DutyModePartial DutyMode = "partial"
DutyModeFull DutyMode = "full"
issueDutyPartialAssignment = "DUTY_PARTIAL_ASSIGNMENT"
issueDutyAssignmentRequired = "DUTY_ASSIGNMENT_REQUIRED"
issueNoGovernedDocument = "NO_GOVERNED_DOCUMENT"
issueDutyAssigneeNotActive = "DUTY_ASSIGNEE_NOT_ACTIVE_MEMBER"
issueSeparationOfDutyViolation = "SEPARATION_OF_DUTY_VIOLATION"
)
func classifyDocDuty(d DocDuty) DutyMode {
ids := []int64{d.AuthorUserID, d.ReviewerUserID, d.ApproverUserID}
assigned := 0
for _, id := range ids {
if id < 0 {
return DutyModePartial
}
if id > 0 {
assigned++
}
}
switch assigned {
case 0:
return DutyModeNone
case 3:
return DutyModeFull
default:
return DutyModePartial
}
}
func docTaskRequiresDuty(td workflowdef.DocTaskDef) bool {
return td.Required || td.Blocking || !td.IsCreatorConfigurable()
}
func normalizeDocTaskDuties(dt DocTaskPlan) DocTaskPlan {
if !dt.Enabled || classifyDocDuty(dt.Duties) == DutyModeNone {
dt.Duties = DocDuty{}
}
return dt
}
func normalizeWorkflowPlanDuties(plan WorkflowPlan) WorkflowPlan {
for i := range plan.Stages {
for j := range plan.Stages[i].DocTasks {
plan.Stages[i].DocTasks[j] = normalizeDocTaskDuties(plan.Stages[i].DocTasks[j])
}
}
return plan
}
func validateDocTaskDuties(dt DocTaskPlan, td workflowdef.DocTaskDef) []workflowdef.ValidationIssue {
if !dt.Enabled {
return nil
}
mode := classifyDocDuty(dt.Duties)
field := dt.TaskKey + ".duties"
switch mode {
case DutyModePartial:
return []workflowdef.ValidationIssue{{
Code: issueDutyPartialAssignment,
Field: field,
Message: fmt.Sprintf("文档「%s」不能只指派部分职责", dutyTaskLabel(dt)),
}}
case DutyModeNone:
if docTaskRequiresDuty(td) {
return []workflowdef.ValidationIssue{{
Code: issueDutyAssignmentRequired,
Field: field,
Message: fmt.Sprintf("文档「%s」必须完整指派编制、审核、批准", dutyTaskLabel(dt)),
}}
}
return nil
default:
if dt.Duties.AuthorUserID == dt.Duties.ReviewerUserID ||
dt.Duties.AuthorUserID == dt.Duties.ApproverUserID ||
dt.Duties.ReviewerUserID == dt.Duties.ApproverUserID {
return []workflowdef.ValidationIssue{{
Code: issueSeparationOfDutyViolation,
Field: dt.TaskKey,
Message: "作者、审核人、批准人必须分离",
}}
}
return nil
}
}
func countFullDutyDocs(plan WorkflowPlan) int {
n := 0
for _, st := range plan.Stages {
if !st.Enabled {
continue
}
for _, dt := range st.DocTasks {
if dt.Enabled && classifyDocDuty(dt.Duties) == DutyModeFull {
n++
}
}
}
return n
}
func validateActiveMemberDuties(plan WorkflowPlan, active map[int64]struct{}) []workflowdef.ValidationIssue {
if active == nil {
return nil
}
issues := make([]workflowdef.ValidationIssue, 0)
for _, st := range plan.Stages {
if !st.Enabled {
continue
}
for _, dt := range st.DocTasks {
if !dt.Enabled || classifyDocDuty(dt.Duties) != DutyModeFull {
continue
}
for _, item := range []struct {
id int64
role string
label string
}{
{dt.Duties.AuthorUserID, "author", "编制"},
{dt.Duties.ReviewerUserID, "reviewer", "审核"},
{dt.Duties.ApproverUserID, "approver", "批准"},
} {
if _, ok := active[item.id]; ok {
continue
}
issues = append(issues, workflowdef.ValidationIssue{
Code: issueDutyAssigneeNotActive,
Field: dt.TaskKey + "." + item.role,
Message: fmt.Sprintf("文档「%s」的%s负责人不是在任项目成员", dutyTaskLabel(dt), item.label),
})
}
}
}
return issues
}
func dutyTaskLabel(dt DocTaskPlan) string {
if dt.TaskTitle != "" {
return dt.TaskTitle
}
return dt.TaskKey
}
+73
View File
@@ -0,0 +1,73 @@
package projectinstance
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/docduty"
"chat-agent-system/internal/workflowdef"
)
// ActiveMemberLookup 只读在任成员集合。validator 不得直连 DB/HTTP。
type ActiveMemberLookup interface {
ActiveMemberIDs(ctx context.Context, projectID int64) (map[int64]struct{}, error)
}
type pgMemberLookup struct {
pool *pgxpool.Pool
}
func NewPGMemberLookup(pool *pgxpool.Pool) ActiveMemberLookup {
return &pgMemberLookup{pool: pool}
}
func (l *pgMemberLookup) ActiveMemberIDs(ctx context.Context, projectID int64) (map[int64]struct{}, error) {
if l == nil || l.pool == nil {
return map[int64]struct{}{}, nil
}
members, creator, err := docduty.LoadProjectMembers(ctx, l.pool, projectID)
if err != nil {
return nil, err
}
return docduty.ActiveMemberIDs(members, creator), nil
}
func (s *Service) SetActiveMemberLookup(lookup ActiveMemberLookup) {
if s == nil {
return
}
s.members = lookup
}
type activateMemberRecheckKey struct{}
func withActivateMemberRecheck(ctx context.Context, fn func(context.Context) error) context.Context {
if fn == nil {
return ctx
}
return context.WithValue(ctx, activateMemberRecheckKey{}, fn)
}
func runActivateMemberRecheck(ctx context.Context) error {
fn, _ := ctx.Value(activateMemberRecheckKey{}).(func(context.Context) error)
if fn == nil {
return nil
}
return fn(ctx)
}
func (s *Service) checkActiveMembers(ctx context.Context, projectID int64, plan WorkflowPlan) error {
if s == nil || s.members == nil {
return nil
}
ids, err := s.members.ActiveMemberIDs(ctx, projectID)
if err != nil {
return err
}
issues := validateActiveMemberDuties(plan, ids)
if len(issues) == 0 {
return nil
}
return newValidationError(workflowdef.ValidationResult{Valid: false, Issues: issues})
}
+15 -1
View File
@@ -24,6 +24,7 @@ type Service struct {
wfStore VersionResolver
projects projectworkflow.Store
docs DocumentReadinessChecker
members ActiveMemberLookup
}
func NewService(store Store, wfStore VersionResolver, projects projectworkflow.Store, docs DocumentReadinessChecker) *Service {
@@ -149,10 +150,14 @@ func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID
if err != nil {
return nil, err
}
in.Plan = normalizeWorkflowPlanDuties(in.Plan)
validation := validatePlan(in.Plan, version)
if !validation.Valid {
return nil, newValidationError(validation)
}
if err := s.checkActiveMembers(ctx, projectID, in.Plan); err != nil {
return nil, err
}
in.Plan.WorkflowVersionID = version.ID
applyDefinitionLabels(&in.Plan, version)
@@ -206,6 +211,9 @@ func (s *Service) ActivationPreview(ctx context.Context, projectID int64, userID
if !validation.Valid {
return nil, newValidationError(validation)
}
if err := s.checkActiveMembers(ctx, projectID, planInst.ConfigSnapshot); err != nil {
return nil, err
}
token, err := randomToken(24)
if err != nil {
@@ -259,9 +267,15 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i
}
validation := validateActivationPlan(planInst.ConfigSnapshot, version)
if !validation.Valid {
return nil, ErrValidationFailed
return nil, newValidationError(validation)
}
if err := s.checkActiveMembers(ctx, projectID, planInst.ConfigSnapshot); err != nil {
return nil, err
}
ctx = withActivateMemberRecheck(ctx, func(ctx context.Context) error {
return s.checkActiveMembers(ctx, projectID, planInst.ConfigSnapshot)
})
activated, err := s.store.ActivateInstanceWithToken(ctx, in.ConfirmationToken, projectID, planInst, version, selVer, userID, planInst.PlanHash)
if err != nil {
return nil, err
+6
View File
@@ -337,6 +337,9 @@ func (s *pgStore) activateInstanceTx(ctx context.Context, db pgxDB, token string
}
defer tx.Rollback(ctx)
if err := runActivateMemberRecheck(ctx); err != nil {
return nil, err
}
if token != "" {
if err := consumeActivationTokenTx(ctx, tx, token, projectID, planHash); err != nil {
return nil, err
@@ -818,6 +821,9 @@ func (m *memStore) UpsertPlanningInstance(_ context.Context, inst *WorkflowInsta
}
func (m *memStore) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) {
if err := runActivateMemberRecheck(ctx); err != nil {
return nil, err
}
if _, err := m.ConsumeActivationToken(ctx, token, projectID, planHash); err != nil {
return nil, err
}
+9 -36
View File
@@ -91,7 +91,7 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V
})
}
if dt.Enabled {
issues = append(issues, validateDuties(dt.Duties, dt.TaskKey)...)
issues = append(issues, validateDocTaskDuties(dt, td)...)
}
}
}
@@ -122,6 +122,13 @@ func validateActivationPlan(plan WorkflowPlan, version *workflowdef.Version) wor
Code: "EMPTY_WORKFLOW", Message: "至少启用一个文档任务才能激活工作流",
})
}
if countFullDutyDocs(plan) == 0 {
base.Valid = false
base.Issues = append(base.Issues, workflowdef.ValidationIssue{
Code: issueNoGovernedDocument,
Message: "至少一份文档必须纳入人工审批才能激活工作流",
})
}
return base
}
@@ -141,41 +148,7 @@ func countEnabledDocTasks(plan WorkflowPlan) int {
}
func validateDuties(d DocDuty, taskKey string) []workflowdef.ValidationIssue {
issues := make([]workflowdef.ValidationIssue, 0)
if d.AuthorUserID <= 0 {
issues = append(issues, workflowdef.ValidationIssue{
Code: "DUTY_AUTHOR_REQUIRED", Field: taskKey + ".author",
Message: "启用的文档任务必须指定作者",
})
}
if d.ReviewerUserID <= 0 {
issues = append(issues, workflowdef.ValidationIssue{
Code: "DUTY_REVIEWER_REQUIRED", Field: taskKey + ".reviewer",
Message: "启用的文档任务必须指定审核人",
})
}
if d.ApproverUserID <= 0 {
issues = append(issues, workflowdef.ValidationIssue{
Code: "DUTY_APPROVER_REQUIRED", Field: taskKey + ".approver",
Message: "启用的文档任务必须指定批准人",
})
}
ids := []int64{d.AuthorUserID, d.ReviewerUserID, d.ApproverUserID}
for i := 0; i < len(ids); i++ {
if ids[i] <= 0 {
continue
}
for j := i + 1; j < len(ids); j++ {
if ids[j] > 0 && ids[i] == ids[j] {
issues = append(issues, workflowdef.ValidationIssue{
Code: "SEPARATION_OF_DUTY_VIOLATION", Field: taskKey,
Message: "作者、审核人、批准人必须分离",
})
return issues
}
}
}
return issues
return validateDocTaskDuties(DocTaskPlan{TaskKey: taskKey, Enabled: true, Duties: d}, workflowdef.DocTaskDef{Key: taskKey, Required: true})
}
func buildDefaultPlan(version *workflowdef.Version) WorkflowPlan {
+3
View File
@@ -85,6 +85,9 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server {
if wfDefSvc.Available() {
docChecker := projectinstance.NewDocGovChecker(svc.DocGov())
instSvc = projectinstance.NewService(instStore, wfDefSvc.Store(), pwSvc.Store(), docChecker)
if pool != nil {
instSvc.SetActiveMemberLookup(projectinstance.NewPGMemberLookup(pool))
}
}
taskMgr := taskrun.NewManager(pwSvc, svc.ContextManager())
if pool != nil {