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>
171 lines
4.5 KiB
Go
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
|
|
}
|