Files
Agent-System/internal/projectinstance/service.go
T
WeiCHEN ad96f67442 refactor(workflowdef): make definition/runtime deps unidirectional
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.
2026-09-22 21:44:57 +08:00

419 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}