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>
This commit is contained in:
@@ -161,11 +161,17 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
||||
if pool != nil {
|
||||
instSvc.SetActiveMemberLookup(projectinstance.NewPGMemberLookup(pool))
|
||||
}
|
||||
if parts.Gov != nil {
|
||||
instSvc.SetDocumentDutyProjector(&documentDutyProjectorAdapter{gov: parts.Gov})
|
||||
}
|
||||
}
|
||||
taskMgr := taskrun.NewManager(pwSvc, parts.Ctx)
|
||||
if pool != nil {
|
||||
taskMgr.SetStore(taskrun.NewPGStore(pool))
|
||||
}
|
||||
if instSvc != nil && parts.Gov != nil {
|
||||
instSvc.SetDocTaskBusyChecker(&docTaskBusyCheckerAdapter{gov: parts.Gov, taskRuns: taskMgr})
|
||||
}
|
||||
wsCap := workspace.NewCapability(cfg.DataDir, workspace.NewLeaseTable())
|
||||
cmds := &taskrun.Commands{
|
||||
Ctx: parts.Ctx,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
"chat-agent-system/internal/taskrun"
|
||||
)
|
||||
|
||||
type documentDutyProjectorAdapter struct {
|
||||
gov *docgov.GovernanceEngine
|
||||
}
|
||||
|
||||
func (a *documentDutyProjectorAdapter) ProjectDocTaskDuties(
|
||||
ctx context.Context,
|
||||
projectID int64,
|
||||
taskKey string,
|
||||
duties projectinstance.DocDuty,
|
||||
dutyPolicy string,
|
||||
names projectinstance.DutyDisplayNames,
|
||||
) error {
|
||||
if a == nil || a.gov == nil {
|
||||
return nil
|
||||
}
|
||||
return a.gov.ProjectDocTaskDuties(
|
||||
ctx, projectID, taskKey,
|
||||
duties.AuthorUserID, duties.ReviewerUserID, duties.ApproverUserID,
|
||||
dutyPolicy, names.AuthorName, names.ReviewerName, names.ApproverName,
|
||||
)
|
||||
}
|
||||
|
||||
type docTaskBusyCheckerAdapter struct {
|
||||
gov *docgov.GovernanceEngine
|
||||
taskRuns *taskrun.Manager
|
||||
}
|
||||
|
||||
func (a *docTaskBusyCheckerAdapter) DocTaskSquadBusy(ctx context.Context, projectID int64, taskKey string) (bool, string, error) {
|
||||
if a == nil || a.gov == nil || projectID <= 0 || taskKey == "" {
|
||||
return false, "", nil
|
||||
}
|
||||
doc, err := a.gov.FindByProjectTaskKey(ctx, projectID, taskKey)
|
||||
if err != nil {
|
||||
if err == docgov.ErrDocumentNotFound {
|
||||
return false, "", nil
|
||||
}
|
||||
return false, "", err
|
||||
}
|
||||
if doc == nil || !doc.HasActiveLease(time.Now()) {
|
||||
return false, "", nil
|
||||
}
|
||||
if doc.LeaseHolderType == docgov.LeaseHolderSquad && a.taskRuns != nil {
|
||||
if run, runErr := a.taskRuns.GetRun(doc.LeaseHolderID); runErr == nil && run.Status != "running" {
|
||||
return false, "", nil
|
||||
}
|
||||
}
|
||||
reason := fmt.Sprintf("该文档小队任务进行中(%s/%s),完成后可更换负责人", doc.LeaseHolderType, doc.LeaseHolderID)
|
||||
return true, reason, nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package docgov
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FindByProjectTaskKey 按项目 + task_key 查找文档(无则 ErrDocumentNotFound)。
|
||||
func (e *GovernanceEngine) FindByProjectTaskKey(ctx context.Context, projectID int64, taskKey string) (*DocumentRecord, error) {
|
||||
if e == nil || projectID <= 0 {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
taskKey = strings.TrimSpace(taskKey)
|
||||
if taskKey == "" {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if e.pool != nil {
|
||||
var docID string
|
||||
err := e.pool.QueryRow(ctx, `
|
||||
SELECT doc_id FROM document_records
|
||||
WHERE project_id = $1 AND task_key = $2
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
`, projectID, taskKey).Scan(&docID)
|
||||
if err != nil {
|
||||
// fall through to mem
|
||||
} else if docID != "" {
|
||||
return e.ensureDocumentLocked(ctx, docID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, doc := range e.memDocs {
|
||||
if doc != nil && doc.ProjectID == projectID && doc.TaskKey == taskKey {
|
||||
cp := *doc
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
|
||||
// ProjectDocTaskDuties 受控改派:更新 document_records 三职投影(含 duty_locked)。
|
||||
func (e *GovernanceEngine) ProjectDocTaskDuties(
|
||||
ctx context.Context,
|
||||
projectID int64,
|
||||
taskKey string,
|
||||
authorID, reviewerID, approverID int64,
|
||||
dutyPolicy string,
|
||||
authorName, reviewerName, approverName string,
|
||||
) error {
|
||||
if e == nil || projectID <= 0 {
|
||||
return nil
|
||||
}
|
||||
taskKey = strings.TrimSpace(taskKey)
|
||||
if taskKey == "" {
|
||||
return nil
|
||||
}
|
||||
if authorName == "" {
|
||||
authorName = fmt.Sprintf("user-%d", authorID)
|
||||
}
|
||||
if reviewerName == "" {
|
||||
reviewerName = fmt.Sprintf("user-%d", reviewerID)
|
||||
}
|
||||
if approverName == "" {
|
||||
approverName = fmt.Sprintf("user-%d", approverID)
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if e.pool != nil {
|
||||
tag, err := e.pool.Exec(ctx, `
|
||||
UPDATE document_records SET
|
||||
author_id = NULLIF($3, 0), author_name = $4,
|
||||
reviewer_id = NULLIF($5, 0), reviewer_name = $6,
|
||||
approver_id = NULLIF($7, 0), approver_name = $8,
|
||||
duty_policy = $9,
|
||||
updated_at = $10
|
||||
WHERE project_id = $1 AND task_key = $2
|
||||
`, projectID, taskKey,
|
||||
authorID, authorName, reviewerID, reviewerName, approverID, approverName,
|
||||
strings.TrimSpace(dutyPolicy), now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("docgov: project doc task duties: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return nil // 文档尚未生成,仅实例侧已更新
|
||||
}
|
||||
}
|
||||
|
||||
for _, doc := range e.memDocs {
|
||||
if doc == nil || doc.ProjectID != projectID || doc.TaskKey != taskKey {
|
||||
continue
|
||||
}
|
||||
doc.AuthorID = authorID
|
||||
doc.AuthorName = authorName
|
||||
doc.ReviewerID = reviewerID
|
||||
doc.ReviewerName = reviewerName
|
||||
doc.ApproverID = approverID
|
||||
doc.ApproverName = approverName
|
||||
doc.DutyPolicy = strings.TrimSpace(dutyPolicy)
|
||||
doc.UpdatedAt = now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubBusyChecker struct {
|
||||
busy bool
|
||||
reason string
|
||||
}
|
||||
|
||||
func (s *stubBusyChecker) DocTaskSquadBusy(context.Context, int64, string) (bool, string, error) {
|
||||
return s.busy, s.reason, nil
|
||||
}
|
||||
|
||||
type stubProjector struct {
|
||||
calls int
|
||||
last DocDuty
|
||||
}
|
||||
|
||||
func (s *stubProjector) ProjectDocTaskDuties(_ context.Context, _ int64, _ string, duties DocDuty, _ string, _ DutyDisplayNames) error {
|
||||
s.calls++
|
||||
s.last = duties
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestReassignDocTaskDuties_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 80, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}, 2: {}, 3: {}, 4: {}}})
|
||||
mustActivateTestPlan(t, svc, 80, 1)
|
||||
|
||||
proj := &stubProjector{}
|
||||
svc.SetDocumentDutyProjector(proj)
|
||||
svc.SetDocTaskBusyChecker(&stubBusyChecker{busy: false})
|
||||
|
||||
settings, err := svc.ReassignDocTaskDuties(ctx, 80, 1, true, ReassignDocTaskDutiesInput{
|
||||
TaskKey: "intake",
|
||||
Duties: DocDuty{AuthorUserID: 4, ReviewerUserID: 2, ApproverUserID: 3},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reassign: %v", err)
|
||||
}
|
||||
if settings == nil || settings.LiveInstance == nil {
|
||||
t.Fatal("expected live settings")
|
||||
}
|
||||
dt := findDocTaskPlan(settings.LiveInstance.ConfigSnapshot, "intake")
|
||||
if dt == nil || dt.Duties.AuthorUserID != 4 {
|
||||
t.Fatalf("duties not updated: %+v", dt)
|
||||
}
|
||||
if proj.calls != 1 || proj.last.AuthorUserID != 4 {
|
||||
t.Fatalf("projector not called: %+v", proj)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassignDocTaskDuties_Busy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 81, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
svc.SetActiveMemberLookup(&mapMemberLookup{ids: map[int64]struct{}{1: {}, 2: {}, 3: {}}})
|
||||
mustActivateTestPlan(t, svc, 81, 1)
|
||||
svc.SetDocTaskBusyChecker(&stubBusyChecker{busy: true, reason: "squad running"})
|
||||
|
||||
_, err := svc.ReassignDocTaskDuties(ctx, 81, 1, true, ReassignDocTaskDutiesInput{
|
||||
TaskKey: "intake",
|
||||
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
|
||||
})
|
||||
var busy *DocTaskBusyError
|
||||
if !errors.As(err, &busy) {
|
||||
t.Fatalf("expected DocTaskBusyError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassignDocTaskDuties_Forbidden(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 82, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
mustActivateTestPlan(t, svc, 82, 1)
|
||||
|
||||
_, err := svc.ReassignDocTaskDuties(ctx, 82, 1, false, ReassignDocTaskDutiesInput{
|
||||
TaskKey: "intake",
|
||||
Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3},
|
||||
})
|
||||
if !errors.Is(err, ErrForbiddenDutyReassign) {
|
||||
t.Fatalf("expected forbidden, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassignDocTaskDuties_PlanStillFrozen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 83, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
mustActivateTestPlan(t, svc, 83, 1)
|
||||
|
||||
_, err := svc.SaveWorkflowPlan(ctx, 83, 1, true, SavePlanInput{
|
||||
Plan: validPlan(),
|
||||
ExpectedPlanRevision: 1,
|
||||
ExpectedSelectionVersion: 2,
|
||||
})
|
||||
if !errors.Is(err, ErrPlanFrozen) {
|
||||
t.Fatalf("expected plan frozen after activate, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,33 @@ var (
|
||||
ErrAuthorOnlyPolicyDisabled = errors.New("author-only approval policy disabled")
|
||||
ErrAuthorOnlyPolicyUnavailable = errors.New("author-only approval policy store unavailable")
|
||||
ErrAuthorOnlyPolicyChanged = errors.New("author-only approval policy changed since preview")
|
||||
ErrForbiddenDutyReassign = errors.New("forbidden: not allowed to reassign document duties")
|
||||
ErrDocTaskNotFound = errors.New("document task not found or disabled")
|
||||
)
|
||||
|
||||
// DocTaskBusyError 文档小队任务运行中,禁止改派。
|
||||
type DocTaskBusyError struct {
|
||||
TaskKey string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *DocTaskBusyError) Error() string {
|
||||
if e == nil {
|
||||
return "document task busy"
|
||||
}
|
||||
if e.Reason != "" {
|
||||
return e.Reason
|
||||
}
|
||||
return "document task busy: squad run in progress"
|
||||
}
|
||||
|
||||
func (e *DocTaskBusyError) Is(target error) bool {
|
||||
_, ok := target.(*DocTaskBusyError)
|
||||
return ok
|
||||
}
|
||||
|
||||
var ErrDocTaskBusy = &DocTaskBusyError{}
|
||||
|
||||
// ValidationError 保留具体校验问题,供 HTTP 层返回可操作的错误信息。
|
||||
type ValidationError struct {
|
||||
Issues []workflowdef.ValidationIssue
|
||||
|
||||
@@ -26,6 +26,8 @@ type Service struct {
|
||||
docs DocumentReadinessChecker
|
||||
members ActiveMemberLookup
|
||||
authorOnlyPolicy AuthorOnlyPolicySource
|
||||
projector DocumentDutyProjector
|
||||
busyChecker DocTaskBusyChecker
|
||||
}
|
||||
|
||||
func NewService(store Store, wfStore VersionResolver, projects projectworkflow.Store, docs DocumentReadinessChecker) *Service {
|
||||
@@ -79,6 +81,9 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64
|
||||
settings.LiveInstance = liveInst
|
||||
settings.CanEditPlan = false
|
||||
settings.CanActivate = false
|
||||
// 激活后权威规划在 live snapshot(planning 行已转为 active)。
|
||||
snap := liveInst.ConfigSnapshot
|
||||
settings.Plan = &snap
|
||||
if detail, detailErr := s.store.GetInstanceByID(ctx, liveInst.ID); detailErr == nil && detail != nil {
|
||||
settings.ActiveStageKey = detail.ActiveStageKey
|
||||
if detail.ActiveStageKey != "" && s.docs != nil {
|
||||
|
||||
@@ -58,6 +58,8 @@ type Store interface {
|
||||
GetInstanceByID(ctx context.Context, instanceID int64) (*InstanceDetail, error)
|
||||
AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int, operatorID int64) (*InstanceDetail, error)
|
||||
MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error
|
||||
// UpdateLiveDocTaskDuties 更新 live 实例 config_snapshot 与 document_duty_assignments(单任务)。
|
||||
UpdateLiveDocTaskDuties(ctx context.Context, projectID int64, taskKey string, plan WorkflowPlan, assignedBy int64) (*WorkflowInstance, error)
|
||||
}
|
||||
|
||||
// NewStore 构建 PG 或内存 Store。
|
||||
@@ -684,6 +686,89 @@ func (s *pgStore) ConsumeActivationToken(ctx context.Context, token string, proj
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) UpdateLiveDocTaskDuties(ctx context.Context, projectID int64, taskKey string, plan WorkflowPlan, assignedBy int64) (*WorkflowInstance, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
live, err := s.GetLiveInstance(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if live == nil {
|
||||
return nil, ErrInstanceNotFound
|
||||
}
|
||||
|
||||
planHash, err := computePlanHash(plan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapRaw, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
row := tx.QueryRow(ctx, `
|
||||
UPDATE project_workflow_instances SET
|
||||
config_snapshot = $2, plan_hash = $3, config_revision = config_revision + 1, updated_at = $4
|
||||
WHERE id = $1 AND status IN ('active', 'awaiting_stage_confirmation')
|
||||
RETURNING id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash,
|
||||
config_snapshot, activated_by, activated_at, created_by, created_at, updated_at
|
||||
`, live.ID, snapRaw, planHash, now)
|
||||
inst, err := scanInstance(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrInstanceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var docTaskID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT dti.id FROM project_doc_task_instances dti
|
||||
JOIN project_stage_instances si ON si.id = dti.stage_instance_id
|
||||
WHERE si.workflow_instance_id = $1 AND dti.task_key = $2
|
||||
`, inst.ID, taskKey).Scan(&docTaskID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrDocTaskNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM document_duty_assignments WHERE doc_task_instance_id = $1`, docTaskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dt := findDocTaskPlan(plan, taskKey)
|
||||
if dt == nil {
|
||||
return nil, ErrDocTaskNotFound
|
||||
}
|
||||
mode, effective, _ := ResolveDocTaskDuty(*dt)
|
||||
if mode != DutyModeNone {
|
||||
for role, uid := range map[string]int64{
|
||||
DutyAuthor: effective.AuthorUserID, DutyReviewer: effective.ReviewerUserID, DutyApprover: effective.ApproverUserID,
|
||||
} {
|
||||
if uid <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO document_duty_assignments (doc_task_instance_id, duty_role, user_id, assigned_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, docTaskID, role, uid, assignedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -1107,6 +1192,29 @@ func (m *memStore) MarkDocTaskApproved(_ context.Context, projectID int64, taskK
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) UpdateLiveDocTaskDuties(_ context.Context, projectID int64, taskKey string, plan WorkflowPlan, _ int64) (*WorkflowInstance, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
live := m.live[projectID]
|
||||
if live == nil {
|
||||
return nil, ErrInstanceNotFound
|
||||
}
|
||||
if findDocTaskPlan(plan, taskKey) == nil {
|
||||
return nil, ErrDocTaskNotFound
|
||||
}
|
||||
planHash, err := computePlanHash(plan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copyInst := *live
|
||||
copyInst.ConfigSnapshot = plan
|
||||
copyInst.PlanHash = planHash
|
||||
copyInst.ConfigRevision++
|
||||
copyInst.UpdatedAt = time.Now()
|
||||
m.live[projectID] = ©Inst
|
||||
return ©Inst, nil
|
||||
}
|
||||
|
||||
func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
)
|
||||
|
||||
func (s *Server) handleReassignDocTaskDuties(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || projectID <= 0 {
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`)
|
||||
return
|
||||
}
|
||||
taskKey := r.PathValue("taskKey")
|
||||
if taskKey == "" {
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"invalid task key"}`)
|
||||
return
|
||||
}
|
||||
user, ok := s.requireProjectAccess(w, r, projectID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if s.instSvc == nil || !s.instSvc.Available() {
|
||||
writeSafeError(w, r, http.StatusServiceUnavailable, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`)
|
||||
return
|
||||
}
|
||||
|
||||
isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID)
|
||||
canAssign, permErr := s.userHasSystemPermission(r.Context(), user, "project:assign_lead")
|
||||
if permErr != nil {
|
||||
if s.failClosedAuthz() {
|
||||
writeSafeError(w, r, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"无权改派文档职责"}`)
|
||||
return
|
||||
}
|
||||
canAssign = false
|
||||
}
|
||||
allowed := isCreator || canAssign
|
||||
|
||||
var req projectinstance.ReassignDocTaskDutiesInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
|
||||
return
|
||||
}
|
||||
req.TaskKey = taskKey
|
||||
|
||||
settings, err := s.instSvc.ReassignDocTaskDuties(r.Context(), projectID, user.ID, allowed, req)
|
||||
if err != nil {
|
||||
writeProjectInstanceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, settings)
|
||||
}
|
||||
@@ -204,7 +204,23 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) {
|
||||
writeSafeError(w, nil, http.StatusConflict, `{"error":"AUTHOR_ONLY_POLICY_CHANGED","message":"兼任总授权已变更,请重新预览并确认激活"}`)
|
||||
case errors.Is(err, projectinstance.ErrAuthorOnlyPolicyUnavailable):
|
||||
writeSafeError(w, nil, http.StatusServiceUnavailable, `{"error":"AUTHOR_ONLY_POLICY_UNAVAILABLE","message":"兼任总授权存储不可用"}`)
|
||||
case errors.Is(err, projectinstance.ErrForbiddenDutyReassign):
|
||||
writeSafeError(w, nil, http.StatusForbidden, `{"error":"ERR_FORBIDDEN","message":"无权改派文档职责"}`)
|
||||
case errors.Is(err, projectinstance.ErrDocTaskNotFound):
|
||||
writeSafeError(w, nil, http.StatusNotFound, `{"error":"DOC_TASK_NOT_FOUND","message":"文档任务不存在或已禁用"}`)
|
||||
default:
|
||||
var busy *projectinstance.DocTaskBusyError
|
||||
if errors.As(err, &busy) {
|
||||
msg := busy.Reason
|
||||
if msg == "" {
|
||||
msg = "该文档小队任务进行中,完成后可更换负责人"
|
||||
}
|
||||
writeJSONResponse(w, http.StatusConflict, map[string]string{
|
||||
"error": "DUTY_REASSIGN_BUSY",
|
||||
"message": msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeSafeError(w, nil, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/projects/{id}/settings", s.handleGetProjectSettings)
|
||||
mux.HandleFunc("GET /api/v1/projects/{id}/workflow-plan/default", s.handleGetDefaultWorkflowPlan)
|
||||
mux.HandleFunc("PUT /api/v1/projects/{id}/workflow-plan", s.handleSaveWorkflowPlan)
|
||||
mux.HandleFunc("PATCH /api/v1/projects/{id}/doc-tasks/{taskKey}/duties", s.handleReassignDocTaskDuties)
|
||||
mux.HandleFunc("POST /api/v1/projects/{id}/activation-preview", s.handleActivationPreview)
|
||||
mux.HandleFunc("POST /api/v1/projects/{id}/workflow-instances/activate", s.handleActivateWorkflowInstance)
|
||||
mux.HandleFunc("GET /api/v1/workflow-instances/{id}", s.handleGetWorkflowInstance)
|
||||
|
||||
Reference in New Issue
Block a user