feat(projectinstance): allow live workflow config edits except switching
Activated projects can update duties, approval policy, and document enablement. Changing the workflow definition stays rejected, and project 143 remains frozen. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -165,6 +165,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
||||
}
|
||||
if parts.Gov != nil {
|
||||
instSvc.SetDocumentDutyProjector(&documentDutyProjectorAdapter{gov: parts.Gov})
|
||||
instSvc.SetTemplateDocMaterializer(&templateDocMaterializerAdapter{gov: parts.Gov})
|
||||
}
|
||||
}
|
||||
taskMgr := taskrun.NewManager(pwSvc, parts.Ctx)
|
||||
|
||||
@@ -32,6 +32,33 @@ func (a *documentDutyProjectorAdapter) ProjectDocTaskDuties(
|
||||
)
|
||||
}
|
||||
|
||||
type templateDocMaterializerAdapter struct {
|
||||
gov *docgov.GovernanceEngine
|
||||
}
|
||||
|
||||
func (a *templateDocMaterializerAdapter) MaterializeTemplateDocs(ctx context.Context, projectID int64, plan projectinstance.WorkflowPlan) error {
|
||||
if a == nil || a.gov == nil || projectID <= 0 {
|
||||
return nil
|
||||
}
|
||||
specs := make([]docgov.TemplateDocSpec, 0, len(plan.Stages))
|
||||
for _, stage := range plan.Stages {
|
||||
for _, task := range stage.DocTasks {
|
||||
_, effective, _ := projectinstance.ResolveDocTaskDuty(task)
|
||||
specs = append(specs, docgov.TemplateDocSpec{
|
||||
TaskKey: task.TaskKey,
|
||||
Title: task.TaskTitle,
|
||||
StageKey: stage.StageKey,
|
||||
DutyPolicy: task.DutyPolicy,
|
||||
Enabled: stage.Enabled && task.Enabled,
|
||||
AuthorUserID: effective.AuthorUserID,
|
||||
ReviewerUserID: effective.ReviewerUserID,
|
||||
ApproverUserID: effective.ApproverUserID,
|
||||
})
|
||||
}
|
||||
}
|
||||
return a.gov.MaterializeTemplateDocs(ctx, projectID, specs)
|
||||
}
|
||||
|
||||
type docTaskBusyCheckerAdapter struct {
|
||||
gov *docgov.GovernanceEngine
|
||||
taskRuns *taskrun.Manager
|
||||
|
||||
@@ -7,33 +7,34 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotAvailable = errors.New("project instance store not available")
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow")
|
||||
ErrPlanFrozen = errors.New("plan frozen: workflow already active")
|
||||
ErrWorkflowAlreadyActive = errors.New("workflow already active for project")
|
||||
ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required")
|
||||
ErrInvalidPlan = errors.New("invalid workflow plan")
|
||||
ErrValidationFailed = errors.New("workflow plan validation failed")
|
||||
ErrTokenInvalid = errors.New("activation token invalid or expired")
|
||||
ErrConcurrentConflict = errors.New("conflict: project version mismatch")
|
||||
ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch")
|
||||
ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published")
|
||||
ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation")
|
||||
ErrInstanceNotFound = errors.New("workflow instance not found")
|
||||
ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved")
|
||||
ErrStageNotActive = errors.New("stage is not the active stage")
|
||||
ErrStageNotCompleted = errors.New("stage is not completed")
|
||||
ErrRecheckAckRequired = errors.New("recheck acknowledgement required")
|
||||
ErrInvalidStage = errors.New("invalid stage key for instance")
|
||||
ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch")
|
||||
ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate")
|
||||
ErrTerminationNotAllowed = errors.New("termination not allowed for instance state")
|
||||
ErrAuthorOnlyPolicyDisabled = errors.New("author-only approval policy disabled")
|
||||
ErrNotAvailable = errors.New("project instance store not available")
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow")
|
||||
ErrPlanFrozen = errors.New("plan frozen: workflow already active")
|
||||
ErrWorkflowSwitchFrozen = errors.New("workflow switch frozen: definition cannot change after activation")
|
||||
ErrWorkflowAlreadyActive = errors.New("workflow already active for project")
|
||||
ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required")
|
||||
ErrInvalidPlan = errors.New("invalid workflow plan")
|
||||
ErrValidationFailed = errors.New("workflow plan validation failed")
|
||||
ErrTokenInvalid = errors.New("activation token invalid or expired")
|
||||
ErrConcurrentConflict = errors.New("conflict: project version mismatch")
|
||||
ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch")
|
||||
ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published")
|
||||
ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation")
|
||||
ErrInstanceNotFound = errors.New("workflow instance not found")
|
||||
ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved")
|
||||
ErrStageNotActive = errors.New("stage is not the active stage")
|
||||
ErrStageNotCompleted = errors.New("stage is not completed")
|
||||
ErrRecheckAckRequired = errors.New("recheck acknowledgement required")
|
||||
ErrInvalidStage = errors.New("invalid stage key for instance")
|
||||
ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch")
|
||||
ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate")
|
||||
ErrTerminationNotAllowed = errors.New("termination not allowed for instance state")
|
||||
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")
|
||||
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 文档小队任务运行中,禁止改派。
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UpdateLivePlan 在已激活实例上更新职责、审批策略和文档/阶段启用。
|
||||
// 工作流定义与版本不可变。项目 143 保持冻结。
|
||||
func (s *Service) UpdateLivePlan(ctx context.Context, projectID int64, userID int64, isCreator bool, in SavePlanInput) (*ProjectSettings, error) {
|
||||
if !isCreator {
|
||||
return nil, ErrForbiddenNotCreator
|
||||
}
|
||||
if projectID == readonlyLivePlanProjectID {
|
||||
return nil, ErrPlanFrozen
|
||||
}
|
||||
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
|
||||
}
|
||||
if live.Status == "completed" || live.Status == "terminated" {
|
||||
return nil, ErrPlanFrozen
|
||||
}
|
||||
if in.ExpectedPlanRevision != live.ConfigRevision {
|
||||
return nil, ErrInstanceRevisionConflict
|
||||
}
|
||||
|
||||
plan := normalizeWorkflowPlanDuties(in.Plan)
|
||||
if strings.TrimSpace(plan.WorkflowDefinitionKey) == "" {
|
||||
plan.WorkflowDefinitionKey = live.ConfigSnapshot.WorkflowDefinitionKey
|
||||
}
|
||||
if plan.WorkflowVersion == 0 {
|
||||
plan.WorkflowVersion = live.ConfigSnapshot.WorkflowVersion
|
||||
}
|
||||
if plan.WorkflowVersionID == 0 {
|
||||
plan.WorkflowVersionID = live.WorkflowVersionID
|
||||
}
|
||||
if plan.WorkflowDefinitionKey != live.ConfigSnapshot.WorkflowDefinitionKey ||
|
||||
plan.WorkflowVersion != live.ConfigSnapshot.WorkflowVersion ||
|
||||
plan.WorkflowVersionID != live.WorkflowVersionID {
|
||||
return nil, ErrWorkflowSwitchFrozen
|
||||
}
|
||||
plan.WorkflowVersionID = live.WorkflowVersionID
|
||||
|
||||
version, err := s.resolvePublishedVersion(ctx, plan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validation := validatePlan(plan, version)
|
||||
if !validation.Valid {
|
||||
return nil, newValidationError(validation)
|
||||
}
|
||||
if err := s.checkActiveMembers(ctx, projectID, plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if planUsesAuthorOnly(plan) {
|
||||
if _, err := s.requireAuthorOnlyAllowed(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.rejectBusyDocTaskChanges(ctx, projectID, live.ConfigSnapshot, plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateLivePlan(ctx, projectID, plan, live.ConfigRevision, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.projectLiveDuties(ctx, projectID, updated.ConfigSnapshot)
|
||||
s.materializePlan(ctx, projectID, updated.ConfigSnapshot)
|
||||
return s.GetSettings(ctx, projectID, userID, isCreator)
|
||||
}
|
||||
|
||||
func (s *Service) rejectBusyDocTaskChanges(ctx context.Context, projectID int64, prev, next WorkflowPlan) error {
|
||||
if s == nil || s.busyChecker == nil {
|
||||
return nil
|
||||
}
|
||||
for _, st := range prev.Stages {
|
||||
for _, before := range st.DocTasks {
|
||||
after := findDocTaskPlan(next, before.TaskKey)
|
||||
if after == nil || !liveDocTaskChanged(before, *after) {
|
||||
continue
|
||||
}
|
||||
busy, reason, err := s.busyChecker.DocTaskSquadBusy(ctx, projectID, before.TaskKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if busy {
|
||||
if reason == "" {
|
||||
reason = "该文档小队任务进行中,完成后可更换负责人"
|
||||
}
|
||||
return &DocTaskBusyError{TaskKey: before.TaskKey, Reason: reason}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func liveDocTaskChanged(before, after DocTaskPlan) bool {
|
||||
if before.Enabled != after.Enabled || before.DutyPolicy != after.DutyPolicy {
|
||||
return true
|
||||
}
|
||||
return before.Duties != after.Duties
|
||||
}
|
||||
|
||||
func (s *Service) projectLiveDuties(ctx context.Context, projectID int64, plan WorkflowPlan) {
|
||||
if s == nil || s.projector == nil {
|
||||
return
|
||||
}
|
||||
for _, st := range plan.Stages {
|
||||
if !st.Enabled {
|
||||
continue
|
||||
}
|
||||
for _, dt := range st.DocTasks {
|
||||
if !dt.Enabled {
|
||||
continue
|
||||
}
|
||||
mode, effective, _ := ResolveDocTaskDuty(dt)
|
||||
if mode == DutyModeNone {
|
||||
continue
|
||||
}
|
||||
_ = s.projector.ProjectDocTaskDuties(ctx, projectID, dt.TaskKey, effective, dt.DutyPolicy, DutyDisplayNames{})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
type countingMaterializer struct {
|
||||
calls int
|
||||
projectID int64
|
||||
}
|
||||
|
||||
func (c *countingMaterializer) MaterializeTemplateDocs(_ context.Context, projectID int64, _ WorkflowPlan) error {
|
||||
c.calls++
|
||||
c.projectID = projectID
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUpdateLivePlanKeepsWorkflowAndMaterializes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0)
|
||||
mat := &countingMaterializer{}
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
svc.SetTemplateDocMaterializer(mat)
|
||||
|
||||
if _, err := svc.SaveWorkflowPlan(ctx, 42, 1, true, SavePlanInput{
|
||||
Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := svc.ActivationPreview(ctx, 42, 1, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Activate(ctx, 42, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mat.calls != 1 || mat.projectID != 42 {
|
||||
t.Fatalf("activate materialize calls=%d project=%d", mat.calls, mat.projectID)
|
||||
}
|
||||
|
||||
settings, err := svc.GetSettings(ctx, 42, 1, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.PlanEditMode != PlanEditLive {
|
||||
t.Fatalf("plan_edit_mode=%s", settings.PlanEditMode)
|
||||
}
|
||||
plan := *settings.Plan
|
||||
plan.Stages[0].DocTasks[0].Duties.ApproverUserID = 4
|
||||
updated, err := svc.UpdateLivePlan(ctx, 42, 1, true, SavePlanInput{
|
||||
Plan: plan, ExpectedPlanRevision: settings.WorkflowPlanRevision, LiveEdit: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Plan.Stages[0].DocTasks[0].Duties.ApproverUserID != 4 {
|
||||
t.Fatalf("duty not updated: %+v", updated.Plan.Stages[0].DocTasks[0].Duties)
|
||||
}
|
||||
if updated.Plan.WorkflowDefinitionKey != plan.WorkflowDefinitionKey {
|
||||
t.Fatal("workflow key changed")
|
||||
}
|
||||
if mat.calls != 2 {
|
||||
t.Fatalf("live update materialize calls=%d", mat.calls)
|
||||
}
|
||||
|
||||
switched := plan
|
||||
switched.WorkflowDefinitionKey = "other"
|
||||
_, err = svc.UpdateLivePlan(ctx, 42, 1, true, SavePlanInput{
|
||||
Plan: switched, ExpectedPlanRevision: updated.WorkflowPlanRevision,
|
||||
})
|
||||
if !errors.Is(err, ErrWorkflowSwitchFrozen) {
|
||||
t.Fatalf("expected switch frozen, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLivePlanRejectsReadonlyProject(t *testing.T) {
|
||||
svc := NewService(newMemStore(), testResolver(), nil, nil)
|
||||
_, err := svc.UpdateLivePlan(context.Background(), readonlyLivePlanProjectID, 1, true, SavePlanInput{
|
||||
Plan: validPlan(), ExpectedPlanRevision: 1, LiveEdit: true,
|
||||
})
|
||||
if !errors.Is(err, ErrPlanFrozen) {
|
||||
t.Fatalf("expected frozen, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanEditModeFrozenForReadonlyLiveProject(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, readonlyLivePlanProjectID, LifecycleActive, workflowdef.OfficialKeyMain, 2, 1)
|
||||
store.live[readonlyLivePlanProjectID] = &WorkflowInstance{
|
||||
ID: 89,
|
||||
ProjectID: readonlyLivePlanProjectID,
|
||||
WorkflowVersionID: 1,
|
||||
Status: InstanceActive,
|
||||
ConfigRevision: 3,
|
||||
ConfigSnapshot: validPlan(),
|
||||
}
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
settings, err := svc.GetSettings(ctx, readonlyLivePlanProjectID, 1, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.PlanEditMode != PlanEditFrozen {
|
||||
t.Fatalf("plan_edit_mode=%s", settings.PlanEditMode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// readonlyLivePlanProjectID 是历史验收项目。激活后保持只读,禁止改写规划与补文档行。
|
||||
const readonlyLivePlanProjectID int64 = 143
|
||||
|
||||
// TemplateDocMaterializer 把已启用文档任务物化到 document_records。
|
||||
// 实现放在 app 适配层,避免 projectinstance 依赖 docgov。
|
||||
type TemplateDocMaterializer interface {
|
||||
MaterializeTemplateDocs(ctx context.Context, projectID int64, plan WorkflowPlan) error
|
||||
}
|
||||
|
||||
func (s *Service) SetTemplateDocMaterializer(m TemplateDocMaterializer) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.templateDocs = m
|
||||
}
|
||||
|
||||
// MaterializeLiveDocs 按 live 快照补齐受控文档台账。列表接口可重复调用。
|
||||
func (s *Service) MaterializeLiveDocs(ctx context.Context, projectID int64) error {
|
||||
if s == nil || s.templateDocs == nil || s.store == nil || projectID <= 0 || projectID == readonlyLivePlanProjectID {
|
||||
return nil
|
||||
}
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil {
|
||||
return err
|
||||
}
|
||||
return s.templateDocs.MaterializeTemplateDocs(ctx, projectID, live.ConfigSnapshot)
|
||||
}
|
||||
|
||||
func (s *Service) materializePlan(ctx context.Context, projectID int64, plan WorkflowPlan) {
|
||||
if s == nil || s.templateDocs == nil || projectID <= 0 || projectID == readonlyLivePlanProjectID {
|
||||
return
|
||||
}
|
||||
if err := s.templateDocs.MaterializeTemplateDocs(ctx, projectID, plan); err != nil {
|
||||
slog.Warn("materialize template docs", "project_id", projectID, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ type Service struct {
|
||||
authorOnlyPolicy AuthorOnlyPolicySource
|
||||
projector DocumentDutyProjector
|
||||
busyChecker DocTaskBusyChecker
|
||||
templateDocs TemplateDocMaterializer
|
||||
recheck RecheckGate
|
||||
}
|
||||
|
||||
@@ -94,6 +95,7 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64
|
||||
settings.LiveInstance = liveInst
|
||||
settings.CanEditPlan = false
|
||||
settings.CanActivate = false
|
||||
settings.WorkflowPlanRevision = liveInst.ConfigRevision
|
||||
// 激活后权威规划在 live snapshot(planning 行已转为 active)。
|
||||
snap := liveInst.ConfigSnapshot
|
||||
settings.Plan = &snap
|
||||
@@ -137,10 +139,27 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64
|
||||
}
|
||||
}
|
||||
settings.AuthorOnlyPolicy = s.loadAuthorOnlyCapability(ctx)
|
||||
settings.PlanEditMode = planEditModeFor(projectID, lifecycle, liveInst)
|
||||
_ = userID
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func planEditModeFor(projectID int64, lifecycle string, live *WorkflowInstance) string {
|
||||
if projectID == readonlyLivePlanProjectID && (live != nil || lifecycle != LifecyclePlanning) {
|
||||
return PlanEditFrozen
|
||||
}
|
||||
if live != nil {
|
||||
if live.Status == "completed" || live.Status == "terminated" {
|
||||
return PlanEditFrozen
|
||||
}
|
||||
return PlanEditLive
|
||||
}
|
||||
if lifecycle == LifecyclePlanning {
|
||||
return PlanEditPlanning
|
||||
}
|
||||
return PlanEditFrozen
|
||||
}
|
||||
|
||||
func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID int64, isCreator bool, in SavePlanInput) (*ProjectSettings, error) {
|
||||
if !isCreator {
|
||||
return nil, ErrForbiddenNotCreator
|
||||
@@ -318,6 +337,9 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if activated != nil {
|
||||
s.materializePlan(ctx, projectID, activated.ConfigSnapshot)
|
||||
}
|
||||
|
||||
return &ActivateResult{
|
||||
ProjectID: projectID,
|
||||
|
||||
@@ -62,6 +62,8 @@ type Store interface {
|
||||
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)
|
||||
// UpdateLivePlan 更新 live 快照(职责、审批策略、文档/阶段启用),并同步实例行与职责指派。
|
||||
UpdateLivePlan(ctx context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, assignedBy int64) (*WorkflowInstance, error)
|
||||
}
|
||||
|
||||
// NewStore 构建 PG 或内存 Store。
|
||||
@@ -771,6 +773,118 @@ func (s *pgStore) UpdateLiveDocTaskDuties(ctx context.Context, projectID int64,
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) UpdateLivePlan(ctx context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, assignedBy int64) (*WorkflowInstance, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var instanceID int64
|
||||
var revision int
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, config_revision
|
||||
FROM project_workflow_instances
|
||||
WHERE project_id = $1 AND status IN ('active', 'awaiting_stage_confirmation')
|
||||
FOR UPDATE
|
||||
`, projectID).Scan(&instanceID, &revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrInstanceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if revision != expectedConfigRevision {
|
||||
return nil, ErrInstanceRevisionConflict
|
||||
}
|
||||
|
||||
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 config_revision = $5
|
||||
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
|
||||
`, instanceID, snapRaw, planHash, now, expectedConfigRevision)
|
||||
inst, err := scanInstance(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrInstanceRevisionConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, st := range plan.Stages {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE project_stage_instances
|
||||
SET enabled = $3, updated_at = $4
|
||||
WHERE workflow_instance_id = $1 AND stage_key = $2
|
||||
`, inst.ID, st.StageKey, st.Enabled, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, dt := range st.DocTasks {
|
||||
var docTaskID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE project_doc_task_instances AS dti SET
|
||||
enabled = $3,
|
||||
status = CASE
|
||||
WHEN $3 = false AND dti.status = 'pending' THEN 'skipped_disabled'
|
||||
WHEN $3 = true AND dti.status = 'skipped_disabled' THEN 'pending'
|
||||
ELSE dti.status
|
||||
END,
|
||||
updated_at = $4
|
||||
FROM project_stage_instances si
|
||||
WHERE dti.stage_instance_id = si.id
|
||||
AND si.workflow_instance_id = $1
|
||||
AND dti.task_key = $2
|
||||
RETURNING dti.id
|
||||
`, inst.ID, dt.TaskKey, dt.Enabled, now).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
|
||||
}
|
||||
if !dt.Enabled || !st.Enabled {
|
||||
continue
|
||||
}
|
||||
mode, effective, _ := ResolveDocTaskDuty(dt)
|
||||
if mode == DutyModeNone {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
@@ -834,21 +948,21 @@ type memDocTaskRecord struct {
|
||||
}
|
||||
|
||||
type memStore struct {
|
||||
mu sync.Mutex
|
||||
meta map[int64]projectMeta
|
||||
plan map[int64]*WorkflowInstance
|
||||
live map[int64]*WorkflowInstance
|
||||
tokens map[string]activationTokenRec
|
||||
stages map[int64][]memStageRecord
|
||||
nextStageID int64
|
||||
nextTaskID int64
|
||||
mu sync.Mutex
|
||||
meta map[int64]projectMeta
|
||||
plan map[int64]*WorkflowInstance
|
||||
live map[int64]*WorkflowInstance
|
||||
tokens map[string]activationTokenRec
|
||||
stages map[int64][]memStageRecord
|
||||
nextStageID int64
|
||||
nextTaskID int64
|
||||
}
|
||||
|
||||
type projectMeta struct {
|
||||
lifecycle string
|
||||
activeKey string
|
||||
selectionVer int
|
||||
planRevision int
|
||||
lifecycle string
|
||||
activeKey string
|
||||
selectionVer int
|
||||
planRevision int
|
||||
}
|
||||
|
||||
type activationTokenRec struct {
|
||||
@@ -1276,6 +1390,52 @@ func (m *memStore) UpdateLiveDocTaskDuties(_ context.Context, projectID int64, t
|
||||
return ©Inst, nil
|
||||
}
|
||||
|
||||
func (m *memStore) UpdateLivePlan(_ context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, _ int64) (*WorkflowInstance, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
live := m.live[projectID]
|
||||
if live == nil {
|
||||
return nil, ErrInstanceNotFound
|
||||
}
|
||||
if live.ConfigRevision != expectedConfigRevision {
|
||||
return nil, ErrInstanceRevisionConflict
|
||||
}
|
||||
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
|
||||
|
||||
records := m.stages[copyInst.ID]
|
||||
for i := range records {
|
||||
for _, st := range plan.Stages {
|
||||
if records[i].stageKey == st.StageKey {
|
||||
records[i].enabled = st.Enabled
|
||||
}
|
||||
}
|
||||
for j := range records[i].tasks {
|
||||
dt := findDocTaskPlan(plan, records[i].tasks[j].taskKey)
|
||||
if dt == nil {
|
||||
continue
|
||||
}
|
||||
records[i].tasks[j].enabled = dt.Enabled
|
||||
if !dt.Enabled && records[i].tasks[j].status == "pending" {
|
||||
records[i].tasks[j].status = "skipped_disabled"
|
||||
}
|
||||
if dt.Enabled && records[i].tasks[j].status == "skipped_disabled" {
|
||||
records[i].tasks[j].status = "pending"
|
||||
}
|
||||
}
|
||||
}
|
||||
m.stages[copyInst.ID] = records
|
||||
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()
|
||||
|
||||
@@ -10,6 +10,10 @@ const (
|
||||
LifecyclePlanning = "planning"
|
||||
LifecycleActive = "active"
|
||||
|
||||
PlanEditPlanning = "planning"
|
||||
PlanEditLive = "live"
|
||||
PlanEditFrozen = "frozen"
|
||||
|
||||
InstancePlanning = "planning"
|
||||
InstanceActive = "active"
|
||||
|
||||
@@ -32,10 +36,10 @@ type DocDuty struct {
|
||||
|
||||
// DocTaskPlan 文档任务裁剪与职责。
|
||||
type DocTaskPlan struct {
|
||||
TaskKey string `json:"task_key"`
|
||||
TaskTitle string `json:"task_title,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ApprovalConfigurable bool `json:"approval_configurable,omitempty"`
|
||||
TaskKey string `json:"task_key"`
|
||||
TaskTitle string `json:"task_title,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ApprovalConfigurable bool `json:"approval_configurable,omitempty"`
|
||||
// DutyPolicy 逐文档职责模式;空或缺省按 separated 解读(omitempty 保证旧快照 hash 不变)。
|
||||
DutyPolicy string `json:"duty_policy,omitempty"`
|
||||
Duties DocDuty `json:"duties"`
|
||||
@@ -83,20 +87,22 @@ type GovernancePolicyCapability struct {
|
||||
|
||||
// ProjectSettings 项目设置聚合视图。
|
||||
type ProjectSettings struct {
|
||||
ProjectID int64 `json:"project_id"`
|
||||
LifecycleStatus string `json:"lifecycle_status"`
|
||||
ActiveWorkflowKey string `json:"active_workflow_key"`
|
||||
WorkflowSelectionVersion int `json:"workflow_selection_version"`
|
||||
WorkflowPlanRevision int `json:"workflow_plan_revision"`
|
||||
Plan *WorkflowPlan `json:"plan,omitempty"`
|
||||
PlanningInstance *WorkflowInstance `json:"planning_instance,omitempty"`
|
||||
LiveInstance *WorkflowInstance `json:"live_instance,omitempty"`
|
||||
CanEditPlan bool `json:"can_edit_plan"`
|
||||
CanActivate bool `json:"can_activate"`
|
||||
ActiveStageKey string `json:"active_stage_key,omitempty"`
|
||||
StageAdvanceReady bool `json:"stage_advance_ready,omitempty"`
|
||||
PublishedWorkflows []PublishedOption `json:"published_workflows,omitempty"`
|
||||
AuthorOnlyPolicy GovernancePolicyCapability `json:"author_only_policy"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
LifecycleStatus string `json:"lifecycle_status"`
|
||||
ActiveWorkflowKey string `json:"active_workflow_key"`
|
||||
WorkflowSelectionVersion int `json:"workflow_selection_version"`
|
||||
WorkflowPlanRevision int `json:"workflow_plan_revision"`
|
||||
Plan *WorkflowPlan `json:"plan,omitempty"`
|
||||
PlanningInstance *WorkflowInstance `json:"planning_instance,omitempty"`
|
||||
LiveInstance *WorkflowInstance `json:"live_instance,omitempty"`
|
||||
CanEditPlan bool `json:"can_edit_plan"`
|
||||
CanActivate bool `json:"can_activate"`
|
||||
// PlanEditMode 是 planning | live | frozen。live 允许改职责/审批/启用,禁止切换工作流。
|
||||
PlanEditMode string `json:"plan_edit_mode,omitempty"`
|
||||
ActiveStageKey string `json:"active_stage_key,omitempty"`
|
||||
StageAdvanceReady bool `json:"stage_advance_ready,omitempty"`
|
||||
PublishedWorkflows []PublishedOption `json:"published_workflows,omitempty"`
|
||||
AuthorOnlyPolicy GovernancePolicyCapability `json:"author_only_policy"`
|
||||
}
|
||||
|
||||
// PublishedOption 可选已发布工作流。
|
||||
@@ -113,6 +119,8 @@ type SavePlanInput struct {
|
||||
Plan WorkflowPlan `json:"plan"`
|
||||
ExpectedPlanRevision int `json:"expected_plan_revision"`
|
||||
ExpectedSelectionVersion int `json:"expected_selection_version"`
|
||||
// LiveEdit 为 true 时走激活后的配置更新;缺省仍走规划保存(激活后拒绝)。
|
||||
LiveEdit bool `json:"live_edit,omitempty"`
|
||||
}
|
||||
|
||||
// ActivationPreview 激活预览与确认令牌。
|
||||
@@ -135,18 +143,18 @@ type PreviewDocRef struct {
|
||||
|
||||
// PreviewSummary 激活影响摘要。
|
||||
type PreviewSummary struct {
|
||||
WorkflowName string `json:"workflow_name"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
EnabledStages int `json:"enabled_stages"`
|
||||
EnabledDocTasks int `json:"enabled_doc_tasks"`
|
||||
GovernedDocTasks int `json:"governed_doc_tasks"`
|
||||
UnmanagedDocTasks int `json:"unmanaged_doc_tasks"`
|
||||
AssignedDuties int `json:"assigned_duties"`
|
||||
AuthorOnlyDocTasks int `json:"author_only_doc_tasks,omitempty"`
|
||||
UnmanagedDocuments []PreviewDocRef `json:"unmanaged_documents,omitempty"`
|
||||
AuthorOnlyDocuments []PreviewDocRef `json:"author_only_documents,omitempty"`
|
||||
FirstStageKey string `json:"first_stage_key,omitempty"`
|
||||
FirstStageName string `json:"first_stage_name,omitempty"`
|
||||
WorkflowName string `json:"workflow_name"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
EnabledStages int `json:"enabled_stages"`
|
||||
EnabledDocTasks int `json:"enabled_doc_tasks"`
|
||||
GovernedDocTasks int `json:"governed_doc_tasks"`
|
||||
UnmanagedDocTasks int `json:"unmanaged_doc_tasks"`
|
||||
AssignedDuties int `json:"assigned_duties"`
|
||||
AuthorOnlyDocTasks int `json:"author_only_doc_tasks,omitempty"`
|
||||
UnmanagedDocuments []PreviewDocRef `json:"unmanaged_documents,omitempty"`
|
||||
AuthorOnlyDocuments []PreviewDocRef `json:"author_only_documents,omitempty"`
|
||||
FirstStageKey string `json:"first_stage_key,omitempty"`
|
||||
FirstStageName string `json:"first_stage_name,omitempty"`
|
||||
}
|
||||
|
||||
// ActivateInput 二次确认激活。
|
||||
@@ -170,8 +178,8 @@ const (
|
||||
StageCompleted = "completed"
|
||||
StageSkipped = "skipped"
|
||||
|
||||
InstanceCompleted = "completed"
|
||||
InstanceTerminated = "terminated"
|
||||
InstanceCompleted = "completed"
|
||||
InstanceTerminated = "terminated"
|
||||
InstanceAwaitingStageConfirmation = "awaiting_stage_confirmation"
|
||||
InstanceTerminationRequested = "termination_requested"
|
||||
)
|
||||
@@ -227,25 +235,25 @@ type GateIssue struct {
|
||||
|
||||
// DocTaskReadiness 文档任务就绪状态。
|
||||
type DocTaskReadiness struct {
|
||||
TaskKey string `json:"task_key"`
|
||||
Required bool `json:"required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ApprovalManaged bool `json:"approval_managed"`
|
||||
DocState string `json:"doc_state"`
|
||||
Approved bool `json:"approved"`
|
||||
TaskKey string `json:"task_key"`
|
||||
Required bool `json:"required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ApprovalManaged bool `json:"approval_managed"`
|
||||
DocState string `json:"doc_state"`
|
||||
Approved bool `json:"approved"`
|
||||
}
|
||||
|
||||
// StageReadiness 阶段推进就绪评估。
|
||||
type StageReadiness struct {
|
||||
InstanceID int64 `json:"instance_id"`
|
||||
StageKey string `json:"stage_key"`
|
||||
StageName string `json:"stage_name"`
|
||||
Ready bool `json:"ready"`
|
||||
CanAdvance bool `json:"can_advance"`
|
||||
RequiresRecheckAck bool `json:"requires_recheck_ack"`
|
||||
BlockingIssues []GateIssue `json:"blocking_issues"`
|
||||
DocTasks []DocTaskReadiness `json:"doc_tasks"`
|
||||
ConfigRevision int `json:"config_revision"`
|
||||
InstanceID int64 `json:"instance_id"`
|
||||
StageKey string `json:"stage_key"`
|
||||
StageName string `json:"stage_name"`
|
||||
Ready bool `json:"ready"`
|
||||
CanAdvance bool `json:"can_advance"`
|
||||
RequiresRecheckAck bool `json:"requires_recheck_ack"`
|
||||
BlockingIssues []GateIssue `json:"blocking_issues"`
|
||||
DocTasks []DocTaskReadiness `json:"doc_tasks"`
|
||||
ConfigRevision int `json:"config_revision"`
|
||||
}
|
||||
|
||||
// AdvanceStageInput 创建者确认推进。
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -83,6 +84,11 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
|
||||
if s.instSvc != nil {
|
||||
if err := s.instSvc.MaterializeLiveDocs(r.Context(), projectID); err != nil {
|
||||
slog.Warn("materialize live docs", "project_id", projectID, "err", err)
|
||||
}
|
||||
}
|
||||
docs, err := s.gov.ListWorkflowDocumentsByProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusInternalServerError, "list project documents failed: "+err.Error())
|
||||
|
||||
@@ -98,7 +98,12 @@ func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request)
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
|
||||
return
|
||||
}
|
||||
settings, err := s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req)
|
||||
var settings *projectinstance.ProjectSettings
|
||||
if req.LiveEdit {
|
||||
settings, err = s.instSvc.UpdateLivePlan(r.Context(), projectID, user.ID, isCreator, req)
|
||||
} else {
|
||||
settings, err = s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req)
|
||||
}
|
||||
if err != nil {
|
||||
writeProjectInstanceError(w, err)
|
||||
return
|
||||
@@ -163,6 +168,8 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) {
|
||||
writeSafeError(w, nil, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可配置工作流"}`)
|
||||
case errors.Is(err, projectinstance.ErrPlanFrozen):
|
||||
writeSafeError(w, nil, http.StatusConflict, `{"error":"PLAN_FROZEN","message":"工作流已激活,规划已冻结"}`)
|
||||
case errors.Is(err, projectinstance.ErrWorkflowSwitchFrozen):
|
||||
writeSafeError(w, nil, http.StatusConflict, `{"error":"WORKFLOW_SWITCH_FROZEN","message":"运行中不能切换工作流,只能调整职责、审批和文档启用"}`)
|
||||
case errors.Is(err, projectinstance.ErrWorkflowAlreadyActive):
|
||||
writeSafeError(w, nil, http.StatusConflict, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例"}`)
|
||||
case errors.Is(err, projectinstance.ErrPlanChangedReconfirm):
|
||||
|
||||
Reference in New Issue
Block a user