Files
Agent-System/internal/projectinstance/service.go
T
weichenandCursor dc629fc4b8 feat(service): implement project workflow plan, preview, and activation flow.
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>
2026-09-13 02:50:23 +00:00

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
}