Files
weichenandCursor 1497473186 feat(projectworkflow): controlled post-activation document duty reassign
Add PATCH /doc-tasks/{taskKey}/duties that updates live snapshot,
assignments, and document_records while blocking when a squad run holds
the document lease. Planning PUT remains PLAN_FROZEN.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 09:48:27 +00:00

171 lines
4.5 KiB
Go

package projectinstance
import (
"context"
"fmt"
"chat-agent-system/internal/workflowdef"
)
// ReassignDocTaskDutiesInput 激活后受控改派单文档三职。
type ReassignDocTaskDutiesInput struct {
TaskKey string `json:"task_key"`
Duties DocDuty `json:"duties"`
DutyPolicy string `json:"duty_policy,omitempty"`
}
// DocumentDutyProjector 将实例职责投影到 document_records(含 duty_locked 行)。
type DocumentDutyProjector interface {
ProjectDocTaskDuties(ctx context.Context, projectID int64, taskKey string, duties DocDuty, dutyPolicy string, names DutyDisplayNames) error
}
// DutyDisplayNames 投影用显示名;空则保留原名或写占位。
type DutyDisplayNames struct {
AuthorName string
ReviewerName string
ApproverName string
}
// DocTaskBusyChecker 改派前检查小队任务是否占用文档。
type DocTaskBusyChecker interface {
DocTaskSquadBusy(ctx context.Context, projectID int64, taskKey string) (busy bool, reason string, err error)
}
// ReassignDocTaskDuties 在 live 实例上改派单个文档任务职责。
// 不放开规划冻结:不修改 lifecycle / selection,仅更新 snapshot 与 assignments。
func (s *Service) ReassignDocTaskDuties(
ctx context.Context,
projectID, userID int64,
allowed bool,
in ReassignDocTaskDutiesInput,
) (*ProjectSettings, error) {
if !allowed {
return nil, ErrForbiddenDutyReassign
}
if projectID <= 0 || in.TaskKey == "" {
return nil, ErrInvalidPlan
}
if !s.Available() {
return nil, ErrNotAvailable
}
live, err := s.store.GetLiveInstance(ctx, projectID)
if err != nil {
return nil, err
}
if live == nil {
return nil, ErrInstanceNotFound
}
plan := cloneWorkflowPlan(live.ConfigSnapshot)
dt := findDocTaskPlanMutable(&plan, in.TaskKey)
if dt == nil || !dt.Enabled {
return nil, ErrDocTaskNotFound
}
if in.DutyPolicy != "" {
dt.DutyPolicy = in.DutyPolicy
}
dt.Duties = in.Duties
*dt = normalizeDocTaskDuties(*dt)
version, verErr := s.resolvePublishedVersion(ctx, plan)
if verErr != nil {
return nil, verErr
}
td := workflowdef.DocTaskDef{Key: dt.TaskKey, Required: true}
if def := findDocTaskDefInVersion(version.Definition, dt.TaskKey); def != nil {
td = *def
}
issues := validateDocTaskDuties(*dt, td)
if len(issues) > 0 {
return nil, newValidationError(workflowdef.ValidationResult{Valid: false, Issues: issues})
}
plan = normalizeWorkflowPlanDuties(plan)
if planUsesAuthorOnly(plan) {
if _, err := s.requireAuthorOnlyAllowed(ctx); err != nil {
return nil, err
}
}
if err := s.checkActiveMembers(ctx, projectID, plan); err != nil {
return nil, err
}
if s.busyChecker != nil {
busy, reason, busyErr := s.busyChecker.DocTaskSquadBusy(ctx, projectID, in.TaskKey)
if busyErr != nil {
return nil, busyErr
}
if busy {
if reason == "" {
reason = fmt.Sprintf("文档任务 %s 小队执行中,完成后可更换负责人", in.TaskKey)
}
return nil, &DocTaskBusyError{TaskKey: in.TaskKey, Reason: reason}
}
}
updated, err := s.store.UpdateLiveDocTaskDuties(ctx, projectID, in.TaskKey, plan, userID)
if err != nil {
return nil, err
}
updatedDT := findDocTaskPlan(updated.ConfigSnapshot, in.TaskKey)
if updatedDT != nil && s.projector != nil {
mode, effective, _ := ResolveDocTaskDuty(*updatedDT)
if mode != DutyModeNone {
_ = s.projector.ProjectDocTaskDuties(ctx, projectID, in.TaskKey, effective, updatedDT.DutyPolicy, DutyDisplayNames{})
}
}
return s.GetSettings(ctx, projectID, userID, true)
}
func (s *Service) SetDocumentDutyProjector(p DocumentDutyProjector) {
if s == nil {
return
}
s.projector = p
}
func (s *Service) SetDocTaskBusyChecker(c DocTaskBusyChecker) {
if s == nil {
return
}
s.busyChecker = c
}
func findDocTaskPlanMutable(plan *WorkflowPlan, taskKey string) *DocTaskPlan {
if plan == nil {
return nil
}
for i := range plan.Stages {
for j := range plan.Stages[i].DocTasks {
if plan.Stages[i].DocTasks[j].TaskKey == taskKey {
return &plan.Stages[i].DocTasks[j]
}
}
}
return nil
}
func findDocTaskDefInVersion(def workflowdef.VersionDefinition, taskKey string) *workflowdef.DocTaskDef {
for _, st := range def.Stages {
for i := range st.DocTasks {
if st.DocTasks[i].Key == taskKey {
return &st.DocTasks[i]
}
}
}
return nil
}
func cloneWorkflowPlan(in WorkflowPlan) WorkflowPlan {
out := in
out.Stages = make([]StagePlan, len(in.Stages))
for i, st := range in.Stages {
out.Stages[i] = st
out.Stages[i].DocTasks = append([]DocTaskPlan(nil), st.DocTasks...)
}
return out
}