- Add Store.SavePlanWithRevision: single-transaction planning upsert + revision bump (bump first with optimistic lock, conflict rolls back before any plan write), closing the SaveWorkflowPlan split-transaction race where plan content and revision could diverge - AdvanceStage now takes operatorID and writes completed_by explicitly instead of SELECT activated_by, so the completing operator is the actual advancing user, not the original activator - Service.SaveWorkflowPlan/AdvanceStage pass userID through; memStore implements SavePlanWithRevision under its single mutex Co-authored-by: Cursor <cursoragent@cursor.com>
386 lines
11 KiB
Go
386 lines
11 KiB
Go
package projectinstance
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/projectworkflow"
|
|
"chat-agent-system/internal/workflowdef"
|
|
)
|
|
|
|
// VersionResolver 解析已发布工作流版本。
|
|
type VersionResolver interface {
|
|
GetDefinitionByKey(ctx context.Context, key string) (*workflowdef.Definition, error)
|
|
GetVersion(ctx context.Context, workflowID int64, version int) (*workflowdef.Version, error)
|
|
ListDefinitions(ctx context.Context) ([]workflowdef.Definition, error)
|
|
}
|
|
|
|
// Service 项目工作流实例与激活领域服务。
|
|
type Service struct {
|
|
store Store
|
|
wfStore VersionResolver
|
|
projects projectworkflow.Store
|
|
docs DocumentReadinessChecker
|
|
members ActiveMemberLookup
|
|
authorOnlyPolicy AuthorOnlyPolicySource
|
|
}
|
|
|
|
func NewService(store Store, wfStore VersionResolver, projects projectworkflow.Store, docs DocumentReadinessChecker) *Service {
|
|
return &Service{store: store, wfStore: wfStore, projects: projects, docs: docs}
|
|
}
|
|
|
|
func (s *Service) Available() bool {
|
|
return s != nil && s.store != nil && s.wfStore != nil
|
|
}
|
|
|
|
func (s *Service) HasLiveInstance(ctx context.Context, projectID int64) (bool, error) {
|
|
if !s.Available() {
|
|
return false, nil
|
|
}
|
|
return s.store.HasLiveInstance(ctx, projectID)
|
|
}
|
|
|
|
func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64, isCreator bool) (*ProjectSettings, error) {
|
|
if projectID <= 0 {
|
|
return nil, ErrProjectNotFound
|
|
}
|
|
lifecycle, activeKey, selVer, planRev, err := s.store.GetProjectMeta(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
settings := &ProjectSettings{
|
|
ProjectID: projectID,
|
|
LifecycleStatus: lifecycle,
|
|
ActiveWorkflowKey: activeKey,
|
|
WorkflowSelectionVersion: selVer,
|
|
WorkflowPlanRevision: planRev,
|
|
CanEditPlan: isCreator && lifecycle == LifecyclePlanning,
|
|
CanActivate: isCreator && lifecycle == LifecyclePlanning,
|
|
}
|
|
|
|
planInst, err := s.store.GetPlanningInstance(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if planInst != nil {
|
|
settings.PlanningInstance = planInst
|
|
settings.Plan = &planInst.ConfigSnapshot
|
|
}
|
|
|
|
liveInst, err := s.store.GetLiveInstance(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if liveInst != nil {
|
|
settings.LiveInstance = liveInst
|
|
settings.CanEditPlan = false
|
|
settings.CanActivate = false
|
|
if detail, detailErr := s.store.GetInstanceByID(ctx, liveInst.ID); detailErr == nil && detail != nil {
|
|
settings.ActiveStageKey = detail.ActiveStageKey
|
|
if detail.ActiveStageKey != "" && s.docs != nil {
|
|
if version, verErr := s.resolvePublishedVersion(ctx, liveInst.ConfigSnapshot); verErr == nil {
|
|
if stagePlan := findStagePlan(liveInst.ConfigSnapshot, detail.ActiveStageKey); stagePlan != nil {
|
|
docStates, _ := s.docs.TaskApprovalStates(ctx, projectID)
|
|
ready, _, _ := evaluateStageReadiness(*stagePlan, findStageDef(version.Definition, detail.ActiveStageKey), docStates)
|
|
settings.StageAdvanceReady = ready
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if s.wfStore != nil {
|
|
defs, listErr := s.wfStore.ListDefinitions(ctx)
|
|
if listErr == nil {
|
|
for _, d := range defs {
|
|
if d.LatestPublishedVersion <= 0 || !workflowdef.IsOfficialWorkflowKey(d.Key) {
|
|
continue
|
|
}
|
|
ver, verErr := s.wfStore.GetVersion(ctx, d.ID, d.LatestPublishedVersion)
|
|
if verErr != nil || ver == nil {
|
|
continue
|
|
}
|
|
name := d.Name
|
|
if name == "" || name == d.Key {
|
|
name = workflowdef.OfficialWorkflowName(d.Key)
|
|
}
|
|
settings.PublishedWorkflows = append(settings.PublishedWorkflows, PublishedOption{
|
|
DefinitionKey: d.Key,
|
|
DefinitionName: name,
|
|
LatestVersion: d.LatestPublishedVersion,
|
|
WorkflowVersion: ver.ID,
|
|
ProductDomain: d.ProductDomain,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
settings.AuthorOnlyPolicy = s.loadAuthorOnlyCapability(ctx)
|
|
_ = userID
|
|
return settings, nil
|
|
}
|
|
|
|
func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID int64, isCreator bool, in SavePlanInput) (*ProjectSettings, error) {
|
|
if !isCreator {
|
|
return nil, ErrForbiddenNotCreator
|
|
}
|
|
lifecycle, _, selVer, planRev, err := s.store.GetProjectMeta(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if lifecycle != LifecyclePlanning {
|
|
return nil, ErrPlanFrozen
|
|
}
|
|
live, err := s.store.HasLiveInstance(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if live {
|
|
return nil, ErrWorkflowAlreadyActive
|
|
}
|
|
if in.ExpectedSelectionVersion > 0 && in.ExpectedSelectionVersion != selVer {
|
|
return nil, ErrConcurrentConflict
|
|
}
|
|
if in.ExpectedPlanRevision != planRev {
|
|
return nil, ErrPlanRevisionConflict
|
|
}
|
|
|
|
version, err := s.resolvePublishedVersion(ctx, in.Plan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
in.Plan = normalizeWorkflowPlanDuties(in.Plan)
|
|
if planUsesAuthorOnly(in.Plan) {
|
|
if _, err := s.requireAuthorOnlyAllowed(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
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)
|
|
planHash, err := computePlanHash(in.Plan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
inst := &WorkflowInstance{
|
|
ProjectID: projectID,
|
|
WorkflowVersionID: version.ID,
|
|
Status: InstancePlanning,
|
|
ConfigRevision: planRev + 1,
|
|
PlanHash: planHash,
|
|
ConfigSnapshot: in.Plan,
|
|
CreatedBy: &userID,
|
|
}
|
|
if _, _, err := s.store.SavePlanWithRevision(ctx, inst, planRev); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.GetSettings(ctx, projectID, userID, isCreator)
|
|
}
|
|
|
|
func (s *Service) ActivationPreview(ctx context.Context, projectID int64, userID int64, isCreator bool) (*ActivationPreview, error) {
|
|
if !isCreator {
|
|
return nil, ErrForbiddenNotCreator
|
|
}
|
|
lifecycle, _, selVer, planRev, err := s.store.GetProjectMeta(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if lifecycle != LifecyclePlanning {
|
|
return nil, ErrPlanFrozen
|
|
}
|
|
planInst, err := s.store.GetPlanningInstance(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if planInst == nil || planInst.PlanHash == "" {
|
|
return nil, fmt.Errorf("%w: no saved plan", ErrInvalidPlan)
|
|
}
|
|
|
|
version, err := s.resolvePublishedVersion(ctx, planInst.ConfigSnapshot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
validation := validateActivationPlan(planInst.ConfigSnapshot, version)
|
|
if !validation.Valid {
|
|
return nil, newValidationError(validation)
|
|
}
|
|
if err := s.checkActiveMembers(ctx, projectID, planInst.ConfigSnapshot); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var policyRevision *int64
|
|
if planUsesAuthorOnly(planInst.ConfigSnapshot) {
|
|
snap, err := s.requireAuthorOnlyAllowed(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rev := snap.Revision
|
|
policyRevision = &rev
|
|
}
|
|
|
|
token, err := randomToken(24)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
expiresAt := time.Now().Add(15 * time.Minute)
|
|
if err := s.store.SaveActivationToken(ctx, token, projectID, planInst.PlanHash, selVer, planInst.ConfigSnapshot, userID, expiresAt, policyRevision); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &ActivationPreview{
|
|
ConfirmationToken: token,
|
|
PlanHash: planInst.PlanHash,
|
|
ExpectedProjectVersion: selVer,
|
|
ExpectedPlanRevision: planRev,
|
|
Summary: previewSummary(planInst.ConfigSnapshot, version),
|
|
Validation: validation,
|
|
ExpiresAt: expiresAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, isCreator bool, in ActivateInput) (*ActivateResult, error) {
|
|
if !isCreator {
|
|
return nil, ErrForbiddenNotCreator
|
|
}
|
|
lifecycle, _, selVer, planRev, err := s.store.GetProjectMeta(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if lifecycle != LifecyclePlanning {
|
|
return nil, ErrPlanFrozen
|
|
}
|
|
if in.ExpectedProjectVersion != selVer {
|
|
return nil, ErrConcurrentConflict
|
|
}
|
|
if in.ExpectedPlanRevision != planRev {
|
|
return nil, ErrPlanRevisionConflict
|
|
}
|
|
|
|
planInst, err := s.store.GetPlanningInstance(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if planInst == nil {
|
|
return nil, fmt.Errorf("%w: no saved plan", ErrInvalidPlan)
|
|
}
|
|
|
|
version, err := s.resolvePublishedVersion(ctx, planInst.ConfigSnapshot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
validation := validateActivationPlan(planInst.ConfigSnapshot, version)
|
|
if !validation.Valid {
|
|
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)
|
|
})
|
|
if planUsesAuthorOnly(planInst.ConfigSnapshot) {
|
|
if s.authorOnlyPolicy == nil {
|
|
return nil, ErrAuthorOnlyPolicyUnavailable
|
|
}
|
|
ctx = withActivateAuthorOnlyLock(ctx, s.authorOnlyPolicy.LockAuthorOnlyApprovalPolicy)
|
|
}
|
|
activated, err := s.store.ActivateInstanceWithToken(ctx, in.ConfirmationToken, projectID, planInst, version, selVer, userID, planInst.PlanHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &ActivateResult{
|
|
ProjectID: projectID,
|
|
LifecycleStatus: LifecycleActive,
|
|
ActiveWorkflowKey: version.WorkflowKey,
|
|
Instance: *activated,
|
|
}, nil
|
|
}
|
|
|
|
// GetLiveInstanceDetail 返回 live 实例详情与已发布版本定义(运行时编排入口)。
|
|
func (s *Service) GetLiveInstanceDetail(ctx context.Context, projectID int64) (*InstanceDetail, *workflowdef.Version, error) {
|
|
if s == nil || s.store == nil {
|
|
return nil, nil, ErrNotAvailable
|
|
}
|
|
live, err := s.store.GetLiveInstance(ctx, projectID)
|
|
if err != nil || live == nil {
|
|
return nil, nil, fmt.Errorf("live instance not found")
|
|
}
|
|
detail, err := s.store.GetInstanceByID(ctx, live.ID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
version, err := s.resolvePublishedVersion(ctx, live.ConfigSnapshot)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return detail, version, nil
|
|
}
|
|
|
|
func (s *Service) BuildDefaultPlan(ctx context.Context, workflowKey string, versionNum int) (*WorkflowPlan, error) {
|
|
if s.wfStore == nil {
|
|
return nil, ErrNotAvailable
|
|
}
|
|
if !workflowdef.IsOfficialWorkflowKey(workflowKey) {
|
|
return nil, ErrWorkflowVersionNotFound
|
|
}
|
|
def, err := s.wfStore.GetDefinitionByKey(ctx, workflowKey)
|
|
if err != nil {
|
|
return nil, ErrWorkflowVersionNotFound
|
|
}
|
|
verNum := versionNum
|
|
if verNum <= 0 {
|
|
verNum = def.LatestPublishedVersion
|
|
}
|
|
version, err := s.wfStore.GetVersion(ctx, def.ID, verNum)
|
|
if err != nil {
|
|
return nil, ErrWorkflowVersionNotFound
|
|
}
|
|
plan := buildDefaultPlan(version)
|
|
if plan.WorkflowDefinitionKey == "" {
|
|
plan.WorkflowDefinitionKey = def.Key
|
|
}
|
|
return &plan, nil
|
|
}
|
|
|
|
func (s *Service) resolvePublishedVersion(ctx context.Context, plan WorkflowPlan) (*workflowdef.Version, error) {
|
|
if s.wfStore == nil {
|
|
return nil, ErrNotAvailable
|
|
}
|
|
def, err := s.wfStore.GetDefinitionByKey(ctx, plan.WorkflowDefinitionKey)
|
|
if err != nil {
|
|
return nil, ErrWorkflowVersionNotFound
|
|
}
|
|
verNum := plan.WorkflowVersion
|
|
if verNum <= 0 {
|
|
verNum = def.LatestPublishedVersion
|
|
}
|
|
version, err := s.wfStore.GetVersion(ctx, def.ID, verNum)
|
|
if err != nil {
|
|
return nil, ErrWorkflowVersionNotFound
|
|
}
|
|
version.WorkflowKey = def.Key
|
|
version.WorkflowName = def.Name
|
|
return version, nil
|
|
}
|
|
|
|
func randomToken(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|