Keep registry key validation local to workflowdef with composition-root injection, adapt FlatCatalog for projectworkflow, and drive the orchestrator through LiveInstancePort views instead of projectinstance.
419 lines
12 KiB
Go
419 lines
12 KiB
Go
package projectinstance
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"time"
|
||
|
||
"chat-agent-system/internal/projectworkflow"
|
||
"chat-agent-system/internal/workflowdef"
|
||
)
|
||
|
||
// Service 项目工作流实例与激活领域服务。
|
||
type Service struct {
|
||
store Store
|
||
wfStore VersionResolver
|
||
projects projectworkflow.Store
|
||
docs DocumentReadinessChecker
|
||
members ActiveMemberLookup
|
||
authorOnlyPolicy AuthorOnlyPolicySource
|
||
projector DocumentDutyProjector
|
||
busyChecker DocTaskBusyChecker
|
||
templateDocs TemplateDocMaterializer
|
||
recheck RecheckGate
|
||
}
|
||
|
||
// RecheckGate is the soft hold on a completed stage. Acknowledgement is not clearance.
|
||
type RecheckGate interface {
|
||
StageNeedsAck(ctx context.Context, projectID int64, stageKey string) (bool, error)
|
||
Acknowledge(ctx context.Context, projectID int64, stageKey string, userID int64) error
|
||
}
|
||
|
||
func (s *Service) SetRecheckGate(g RecheckGate) {
|
||
if s != nil {
|
||
s.recheck = g
|
||
}
|
||
}
|
||
|
||
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
|
||
settings.WorkflowPlanRevision = liveInst.ConfigRevision
|
||
// 激活后权威规划在 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 {
|
||
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)
|
||
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
|
||
}
|
||
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
|
||
}
|
||
if activated != nil {
|
||
s.materializePlan(ctx, projectID, activated.ConfigSnapshot)
|
||
}
|
||
|
||
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
|
||
}
|