Files
Agent-System/internal/projectinstance/service.go
T
weichenandCursor 4a7b8cc26f feat(workflow): reopen a completed stage without pausing the later one
Selected documents return to drafting, and advance asks the creator to acknowledge an open recheck mark.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 15:35:07 +00:00

404 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"
)
// 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
projector DocumentDutyProjector
busyChecker DocTaskBusyChecker
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
// 激活后权威规划在 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)
_ = 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
}