New projectinstance package validates three-duty separation, issues confirmation tokens, and activates immutable workflow instances; new projects start in planning mode. Co-authored-by: Cursor <cursoragent@cursor.com>
320 lines
8.7 KiB
Go
320 lines
8.7 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
|
|
}
|
|
|
|
func NewService(store Store, wfStore VersionResolver, projects projectworkflow.Store) *Service {
|
|
return &Service{store: store, wfStore: wfStore, projects: projects}
|
|
}
|
|
|
|
func (s *Service) Available() bool {
|
|
return s != nil && s.store != 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 s.wfStore != nil {
|
|
defs, listErr := s.wfStore.ListDefinitions(ctx)
|
|
if listErr == nil {
|
|
for _, d := range defs {
|
|
if d.LatestPublishedVersion <= 0 {
|
|
continue
|
|
}
|
|
ver, verErr := s.wfStore.GetVersion(ctx, d.ID, d.LatestPublishedVersion)
|
|
if verErr != nil || ver == nil {
|
|
continue
|
|
}
|
|
settings.PublishedWorkflows = append(settings.PublishedWorkflows, PublishedOption{
|
|
DefinitionKey: d.Key,
|
|
DefinitionName: d.Name,
|
|
LatestVersion: d.LatestPublishedVersion,
|
|
WorkflowVersion: ver.ID,
|
|
ProductDomain: d.ProductDomain,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
_ = 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
|
|
}
|
|
validation := validatePlan(in.Plan, version)
|
|
if !validation.Valid {
|
|
return nil, fmt.Errorf("%w: %d issues", ErrValidationFailed, len(validation.Issues))
|
|
}
|
|
|
|
in.Plan.WorkflowVersionID = version.ID
|
|
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.UpsertPlanningInstance(ctx, inst); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.store.BumpPlanRevision(ctx, projectID, 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 := validatePlan(planInst.ConfigSnapshot, version)
|
|
if !validation.Valid {
|
|
return nil, fmt.Errorf("%w", ErrValidationFailed)
|
|
}
|
|
|
|
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); 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)
|
|
}
|
|
|
|
consumed, err := s.store.ConsumeActivationToken(ctx, in.ConfirmationToken, projectID, planInst.PlanHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
currentHash, err := computePlanHash(*consumed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if currentHash != planInst.PlanHash {
|
|
return nil, ErrPlanChangedReconfirm
|
|
}
|
|
|
|
version, err := s.resolvePublishedVersion(ctx, planInst.ConfigSnapshot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
validation := validatePlan(planInst.ConfigSnapshot, version)
|
|
if !validation.Valid {
|
|
return nil, ErrValidationFailed
|
|
}
|
|
|
|
activated, err := s.store.ActivateInstance(ctx, projectID, planInst, version.WorkflowKey, selVer, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &ActivateResult{
|
|
ProjectID: projectID,
|
|
LifecycleStatus: LifecycleActive,
|
|
ActiveWorkflowKey: version.WorkflowKey,
|
|
Instance: *activated,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) BuildDefaultPlan(ctx context.Context, workflowKey string, versionNum int) (*WorkflowPlan, error) {
|
|
if s.wfStore == nil {
|
|
return nil, ErrNotAvailable
|
|
}
|
|
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)
|
|
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
|
|
}
|