package projectinstance import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "sync" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "chat-agent-system/internal/workflowdef" ) // TablesReady 检查 Phase 3 表是否已迁移。 func TablesReady(ctx context.Context, pool *pgxpool.Pool) bool { if pool == nil { return false } var ok bool err := pool.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'project_workflow_instances' ) `).Scan(&ok) return err == nil && ok } // Store 项目实例持久化接口。 type Store interface { GetProjectMeta(ctx context.Context, projectID int64) (lifecycleStatus string, activeWorkflowKey string, selectionVersion int, planRevision int, err error) UpdateProjectAfterActivate(ctx context.Context, projectID int64, workflowKey string, expectedVersion int, activatedBy int64) error BumpPlanRevision(ctx context.Context, projectID int64, expectedPlanRevision int) (int, error) GetPlanningInstance(ctx context.Context, projectID int64) (*WorkflowInstance, error) GetLiveInstance(ctx context.Context, projectID int64) (*WorkflowInstance, error) // GetGoverningInstanceForAccess 供阶段/文档 ABAC:优先 live;否则最新 completed/terminated; // 从未激活(无行或仅 planning)返回 (nil, nil)。与 HasLiveInstance(仅 active 系)语义分离。 GetGoverningInstanceForAccess(ctx context.Context, projectID int64) (*WorkflowInstance, error) HasLiveInstance(ctx context.Context, projectID int64) (bool, error) UpsertPlanningInstance(ctx context.Context, inst *WorkflowInstance) (*WorkflowInstance, error) ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) CountActiveTaskRuns(ctx context.Context, projectID int64) (int, error) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time) error ConsumeActivationToken(ctx context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) GetInstanceByID(ctx context.Context, instanceID int64) (*InstanceDetail, error) AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int) (*InstanceDetail, error) MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error } // NewStore 构建 PG 或内存 Store。 func NewStore(pool *pgxpool.Pool) Store { if pool != nil && TablesReady(context.Background(), pool) { return &pgStore{pool: pool} } return newMemStore() } type pgStore struct { pool *pgxpool.Pool } func scanInstance(row pgx.Row) (*WorkflowInstance, error) { var inst WorkflowInstance var snapRaw []byte var activatedBy, createdBy *int64 err := row.Scan( &inst.ID, &inst.ProjectID, &inst.WorkflowVersionID, &inst.InstanceNo, &inst.Status, &inst.ConfigRevision, &inst.PlanHash, &snapRaw, &activatedBy, &inst.ActivatedAt, &createdBy, &inst.CreatedAt, &inst.UpdatedAt, ) if err != nil { return nil, err } inst.ActivatedBy = activatedBy inst.CreatedBy = createdBy if len(snapRaw) > 0 { _ = json.Unmarshal(snapRaw, &inst.ConfigSnapshot) } return &inst, nil } func (s *pgStore) GetProjectMeta(ctx context.Context, projectID int64) (string, string, int, int, error) { var lifecycle, activeKey string var selVer, planRev int err := s.pool.QueryRow(ctx, ` SELECT COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0) FROM projects WHERE id = $1 `, projectID).Scan(&lifecycle, &activeKey, &selVer, &planRev) if errors.Is(err, pgx.ErrNoRows) { return "", "", 0, 0, ErrProjectNotFound } return lifecycle, activeKey, selVer, planRev, err } func (s *pgStore) UpdateProjectAfterActivate(ctx context.Context, projectID int64, workflowKey string, expectedVersion int, activatedBy int64) error { tag, err := s.pool.Exec(ctx, ` UPDATE projects SET lifecycle_status = 'active', active_workflow_key = $2, workflow_selection_version = workflow_selection_version + 1, active_workflow_changed_at = now(), active_workflow_changed_by = $4, updated_at = now() WHERE id = $1 AND workflow_selection_version = $3 `, projectID, workflowKey, expectedVersion, activatedBy) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrConcurrentConflict } return nil } func (s *pgStore) BumpPlanRevision(ctx context.Context, projectID int64, expectedPlanRevision int) (int, error) { var newRev int err := s.pool.QueryRow(ctx, ` UPDATE projects SET workflow_plan_revision = workflow_plan_revision + 1, updated_at = now() WHERE id = $1 AND workflow_plan_revision = $2 RETURNING workflow_plan_revision `, projectID, expectedPlanRevision).Scan(&newRev) if errors.Is(err, pgx.ErrNoRows) { return 0, ErrPlanRevisionConflict } return newRev, err } func (s *pgStore) GetPlanningInstance(ctx context.Context, projectID int64) (*WorkflowInstance, error) { row := s.pool.QueryRow(ctx, ` SELECT id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, config_snapshot, activated_by, activated_at, created_by, created_at, updated_at FROM project_workflow_instances WHERE project_id = $1 AND status = 'planning' ORDER BY id DESC LIMIT 1 `, projectID) inst, err := scanInstance(row) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return inst, err } func (s *pgStore) GetLiveInstance(ctx context.Context, projectID int64) (*WorkflowInstance, error) { row := s.pool.QueryRow(ctx, ` SELECT id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, config_snapshot, activated_by, activated_at, created_by, created_at, updated_at FROM project_workflow_instances WHERE project_id = $1 AND status IN ('active', 'awaiting_stage_confirmation', 'termination_requested') ORDER BY id DESC LIMIT 1 `, projectID) inst, err := scanInstance(row) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return inst, err } func (s *pgStore) GetGoverningInstanceForAccess(ctx context.Context, projectID int64) (*WorkflowInstance, error) { live, err := s.GetLiveInstance(ctx, projectID) if err != nil { return nil, err } if live != nil { return live, nil } row := s.pool.QueryRow(ctx, ` SELECT id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, config_snapshot, activated_by, activated_at, created_by, created_at, updated_at FROM project_workflow_instances WHERE project_id = $1 AND status IN ('completed', 'terminated') ORDER BY id DESC LIMIT 1 `, projectID) inst, err := scanInstance(row) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return inst, err } func (s *pgStore) HasLiveInstance(ctx context.Context, projectID int64) (bool, error) { var ok bool err := s.pool.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM project_workflow_instances WHERE project_id = $1 AND status IN ('active', 'awaiting_stage_confirmation', 'termination_requested') ) `, projectID).Scan(&ok) return ok, err } func (s *pgStore) UpsertPlanningInstance(ctx context.Context, inst *WorkflowInstance) (*WorkflowInstance, error) { snapRaw, _ := json.Marshal(inst.ConfigSnapshot) now := time.Now() if inst.CreatedAt.IsZero() { inst.CreatedAt = now } inst.UpdatedAt = now tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) var existingID int64 err = tx.QueryRow(ctx, ` SELECT id FROM project_workflow_instances WHERE project_id = $1 AND status = 'planning' LIMIT 1 `, inst.ProjectID).Scan(&existingID) if errors.Is(err, pgx.ErrNoRows) { err = tx.QueryRow(ctx, ` INSERT INTO project_workflow_instances ( project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, config_snapshot, created_by, created_at, updated_at ) VALUES ($1, $2, 1, 'planning', $3, $4, $5, $6, $7, $8) RETURNING id `, inst.ProjectID, inst.WorkflowVersionID, inst.ConfigRevision, inst.PlanHash, snapRaw, inst.CreatedBy, inst.CreatedAt, inst.UpdatedAt).Scan(&inst.ID) if err != nil { return nil, err } } else if err != nil { return nil, err } else { err = tx.QueryRow(ctx, ` UPDATE project_workflow_instances SET workflow_version_id = $2, config_revision = $3, plan_hash = $4, config_snapshot = $5, updated_at = $6 WHERE id = $1 AND status = 'planning' RETURNING id `, existingID, inst.WorkflowVersionID, inst.ConfigRevision, inst.PlanHash, snapRaw, inst.UpdatedAt).Scan(&inst.ID) if err != nil { return nil, err } } if err := s.replaceInstanceChildren(ctx, tx, inst); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return inst, nil } func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst *WorkflowInstance) error { _, err := tx.Exec(ctx, ` DELETE FROM document_duty_assignments WHERE doc_task_instance_id IN ( SELECT dti.id FROM project_doc_task_instances dti JOIN project_stage_instances si ON si.id = dti.stage_instance_id WHERE si.workflow_instance_id = $1 ) `, inst.ID) if err != nil { return err } _, err = tx.Exec(ctx, ` DELETE FROM project_doc_task_instances WHERE stage_instance_id IN ( SELECT id FROM project_stage_instances WHERE workflow_instance_id = $1 ) `, inst.ID) if err != nil { return err } _, err = tx.Exec(ctx, `DELETE FROM project_stage_instances WHERE workflow_instance_id = $1`, inst.ID) if err != nil { return err } for i, st := range inst.ConfigSnapshot.Stages { position := (i + 1) * 10 var stageID int64 err = tx.QueryRow(ctx, ` INSERT INTO project_stage_instances (workflow_instance_id, stage_key, position, enabled, status) VALUES ($1, $2, $3, $4, 'pending') RETURNING id `, inst.ID, st.StageKey, position, st.Enabled).Scan(&stageID) if err != nil { return err } for _, dt := range st.DocTasks { status := "pending" if !dt.Enabled { status = "skipped_disabled" } var docTaskID int64 err = tx.QueryRow(ctx, ` INSERT INTO project_doc_task_instances (stage_instance_id, task_key, enabled, status) VALUES ($1, $2, $3, $4) RETURNING id `, stageID, dt.TaskKey, dt.Enabled, status).Scan(&docTaskID) if err != nil { return err } if !dt.Enabled { continue } for role, uid := range map[string]int64{ DutyAuthor: dt.Duties.AuthorUserID, DutyReviewer: dt.Duties.ReviewerUserID, DutyApprover: dt.Duties.ApproverUserID, } { if uid <= 0 { continue } _, err = tx.Exec(ctx, ` INSERT INTO document_duty_assignments (doc_task_instance_id, duty_role, user_id, assigned_by) VALUES ($1, $2, $3, $4) `, docTaskID, role, uid, inst.CreatedBy) if err != nil { return err } } } } return nil } func (s *pgStore) ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) { return s.activateInstanceTx(ctx, s.pool, "", projectID, inst, version, expectedVersion, activatedBy, "") } func (s *pgStore) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) { return s.activateInstanceTx(ctx, s.pool, token, projectID, inst, version, expectedVersion, activatedBy, planHash) } func (s *pgStore) activateInstanceTx(ctx context.Context, db pgxDB, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) { workflowKey := "" if version != nil { workflowKey = version.WorkflowKey } tx, err := db.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) if token != "" { if err := consumeActivationTokenTx(ctx, tx, token, projectID, planHash); err != nil { return nil, err } } tag, err := tx.Exec(ctx, ` UPDATE projects SET lifecycle_status = 'active', active_workflow_key = $2, workflow_selection_version = workflow_selection_version + 1, active_workflow_changed_at = now(), active_workflow_changed_by = $4, updated_at = now() WHERE id = $1 AND workflow_selection_version = $3 `, projectID, workflowKey, expectedVersion, activatedBy) if err != nil { return nil, err } if tag.RowsAffected() == 0 { return nil, ErrConcurrentConflict } now := time.Now() row := tx.QueryRow(ctx, ` UPDATE project_workflow_instances SET status = 'active', activated_by = $2, activated_at = $3, updated_at = $3 WHERE id = $1 AND status = 'planning' RETURNING id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, config_snapshot, activated_by, activated_at, created_by, created_at, updated_at `, inst.ID, activatedBy, now) activated, err := scanInstance(row) if err != nil { return nil, err } _, err = tx.Exec(ctx, ` UPDATE project_stage_instances SET status = CASE WHEN enabled THEN 'active' ELSE 'skipped' END, updated_at = now() WHERE workflow_instance_id = $1 AND stage_key = ( SELECT stage_key FROM project_stage_instances WHERE workflow_instance_id = $1 AND enabled = true ORDER BY id ASC LIMIT 1 ) `, inst.ID) if err != nil { return nil, err } _, err = tx.Exec(ctx, ` UPDATE project_stage_instances SET status = 'pending', updated_at = now() WHERE workflow_instance_id = $1 AND status <> 'active' AND enabled = true `, inst.ID) if err != nil { return nil, err } if version != nil { if err := s.applyRuntimeMetadata(ctx, tx, inst.ID, version.Definition); err != nil { return nil, err } } if err := tx.Commit(ctx); err != nil { return nil, err } return activated, nil } type pgxDB interface { Begin(ctx context.Context) (pgx.Tx, error) } func consumeActivationTokenTx(ctx context.Context, tx pgx.Tx, token string, projectID int64, planHash string) error { tokenHash := hashActivationToken(token) var storedHash string var usedAt *time.Time var expiresAt time.Time err := tx.QueryRow(ctx, ` SELECT plan_hash, used_at, expires_at FROM workflow_activation_tokens WHERE token = $1 AND project_id = $2 `, tokenHash, projectID).Scan(&storedHash, &usedAt, &expiresAt) if errors.Is(err, pgx.ErrNoRows) { return ErrTokenInvalid } if err != nil { return err } if usedAt != nil || time.Now().After(expiresAt) || storedHash != planHash { return ErrTokenInvalid } tag, err := tx.Exec(ctx, ` UPDATE workflow_activation_tokens SET used_at = now() WHERE token = $1 AND used_at IS NULL `, tokenHash) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrTokenInvalid } return nil } func (s *pgStore) CountActiveTaskRuns(ctx context.Context, projectID int64) (int, error) { if projectID <= 0 { return 0, nil } var count int err := s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM task_runs WHERE project_id = $1 AND status IN ('pending', 'running') `, projectID).Scan(&count) if err != nil { return 0, nil } return count, nil } func (s *pgStore) applyRuntimeMetadata(ctx context.Context, tx pgx.Tx, instanceID int64, def workflowdef.VersionDefinition) error { taskDefs := map[string]workflowdef.DocTaskDef{} for _, st := range def.Stages { for _, dt := range st.DocTasks { taskDefs[dt.Key] = dt } } // 先完整读取并关闭结果集,再执行 UPDATE: // pgx 在同一事务连接上存在未读完的 rows 时执行 Exec 会报 "conn busy"。 rows, err := tx.Query(ctx, ` SELECT dti.id, dti.task_key FROM project_doc_task_instances dti JOIN project_stage_instances si ON si.id = dti.stage_instance_id WHERE si.workflow_instance_id = $1 `, instanceID) if err != nil { return err } type taskInstanceRow struct { id int64 taskKey string } taskRows := make([]taskInstanceRow, 0) for rows.Next() { var r taskInstanceRow if err := rows.Scan(&r.id, &r.taskKey); err != nil { rows.Close() return err } taskRows = append(taskRows, r) } iterErr := rows.Err() rows.Close() if iterErr != nil { return iterErr } for _, r := range taskRows { td, ok := taskDefs[r.taskKey] if !ok { continue } tplKey := td.TemplateAssetKey tplVer := td.TemplateVersion if td.Template != nil { if td.Template.AssetKey != "" { tplKey = td.Template.AssetKey } if td.Template.Version > 0 { tplVer = td.Template.Version } } if _, err := tx.Exec(ctx, ` UPDATE project_doc_task_instances SET executor_key = $2, quality_gate_key = $3, template_asset_key = $4, template_version = $5, output_doc_type = $6, output_doc_title = $7, updated_at = now() WHERE id = $1 `, r.id, td.ExecutorKey, td.QualityGateKey, tplKey, tplVer, td.OutputDocType, td.OutputDocTitle); err != nil { return err } } return nil } func hashActivationToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func (s *pgStore) SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time) error { raw, _ := json.Marshal(snapshot) tokenHash := hashActivationToken(token) _, err := s.pool.Exec(ctx, ` INSERT INTO workflow_activation_tokens (token, project_id, plan_hash, expected_project_version, config_snapshot, created_by, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7) `, tokenHash, projectID, planHash, expectedVersion, raw, createdBy, expiresAt) return err } func (s *pgStore) ConsumeActivationToken(ctx context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) { tokenHash := hashActivationToken(token) var snapRaw []byte var storedHash string var usedAt *time.Time var expiresAt time.Time err := s.pool.QueryRow(ctx, ` SELECT plan_hash, config_snapshot, used_at, expires_at FROM workflow_activation_tokens WHERE token = $1 AND project_id = $2 `, tokenHash, projectID).Scan(&storedHash, &snapRaw, &usedAt, &expiresAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrTokenInvalid } if err != nil { return nil, err } if usedAt != nil || time.Now().After(expiresAt) || storedHash != planHash { return nil, ErrTokenInvalid } tag, err := s.pool.Exec(ctx, ` UPDATE workflow_activation_tokens SET used_at = now() WHERE token = $1 AND used_at IS NULL `, tokenHash) if err != nil { return nil, err } if tag.RowsAffected() == 0 { return nil, ErrTokenInvalid } var plan WorkflowPlan _ = json.Unmarshal(snapRaw, &plan) return &plan, nil } func (s *pgStore) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) { tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) now := time.Now() row := tx.QueryRow(ctx, ` UPDATE project_workflow_instances SET status = 'terminated', terminated_at = $3, terminated_by = $4, termination_reason = $5, config_revision = config_revision + 1, updated_at = $3 WHERE id = $1 AND project_id = $2 AND status IN ('active', 'awaiting_stage_confirmation', 'termination_requested') RETURNING id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, config_snapshot, activated_by, activated_at, created_by, created_at, updated_at `, instanceID, projectID, now, terminatedBy, reason) inst, err := scanInstance(row) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrTerminationNotAllowed } if err != nil { return nil, err } _, err = tx.Exec(ctx, ` UPDATE projects SET lifecycle_status = 'planning', active_workflow_key = '', updated_at = $2 WHERE id = $1 `, projectID, now) if err != nil { return nil, err } _, _ = tx.Exec(ctx, ` UPDATE task_runs SET status = 'cancelled', updated_at = $2, error_message = 'workflow terminated' WHERE project_id = $1 AND status IN ('pending', 'running') `, projectID, now) if err := tx.Commit(ctx); err != nil { return nil, err } return inst, nil } // ============================================================================= // 内存 Store(单元测试 / 无 PG 迁移) // ============================================================================= type memStageRecord struct { id int64 stageKey string position int enabled bool status string tasks []memDocTaskRecord } type memDocTaskRecord struct { id int64 taskKey string enabled bool status string } type memStore struct { mu sync.Mutex meta map[int64]projectMeta plan map[int64]*WorkflowInstance live map[int64]*WorkflowInstance tokens map[string]activationTokenRec stages map[int64][]memStageRecord nextStageID int64 nextTaskID int64 } type projectMeta struct { lifecycle string activeKey string selectionVer int planRevision int } type activationTokenRec struct { projectID int64 planHash string expectedVer int snapshot WorkflowPlan expiresAt time.Time used bool } func newMemStore() *memStore { return &memStore{ meta: make(map[int64]projectMeta), plan: make(map[int64]*WorkflowInstance), live: make(map[int64]*WorkflowInstance), tokens: make(map[string]activationTokenRec), stages: make(map[int64][]memStageRecord), nextStageID: 1, nextTaskID: 1, } } func (m *memStore) rebuildStagesLocked(inst *WorkflowInstance, activate bool) { records := make([]memStageRecord, 0, len(inst.ConfigSnapshot.Stages)) firstActive := true for i, st := range inst.ConfigSnapshot.Stages { rec := memStageRecord{ id: m.nextStageID, stageKey: st.StageKey, position: i, enabled: st.Enabled, } m.nextStageID++ if !st.Enabled { rec.status = StageSkipped } else if activate && firstActive { rec.status = StageActive firstActive = false } else { rec.status = StagePending } for _, dt := range st.DocTasks { task := memDocTaskRecord{id: m.nextTaskID, taskKey: dt.TaskKey, enabled: dt.Enabled, status: "pending"} m.nextTaskID++ if !dt.Enabled { task.status = "skipped_disabled" } rec.tasks = append(rec.tasks, task) } records = append(records, rec) } m.stages[inst.ID] = records } func (m *memStore) instanceDetailLocked(inst *WorkflowInstance) *InstanceDetail { detail := &InstanceDetail{Instance: *inst, Stages: make([]StageInstance, 0)} for _, rec := range m.stages[inst.ID] { st := StageInstance{ ID: rec.id, StageKey: rec.stageKey, Position: rec.position, Enabled: rec.enabled, Status: rec.status, } if st.Status == StageActive { detail.ActiveStageKey = st.StageKey } for _, t := range rec.tasks { st.DocTasks = append(st.DocTasks, DocTaskInstance{ ID: t.id, TaskKey: t.taskKey, Enabled: t.enabled, Status: t.status, Approved: t.status == "approved", }) } detail.Stages = append(detail.Stages, st) } return detail } func (m *memStore) ensureMeta(projectID int64) projectMeta { if pm, ok := m.meta[projectID]; ok { return pm } pm := projectMeta{lifecycle: LifecyclePlanning, selectionVer: 1} m.meta[projectID] = pm return pm } func (m *memStore) GetProjectMeta(_ context.Context, projectID int64) (string, string, int, int, error) { m.mu.Lock() defer m.mu.Unlock() pm, ok := m.meta[projectID] if !ok { return "", "", 0, 0, ErrProjectNotFound } return pm.lifecycle, pm.activeKey, pm.selectionVer, pm.planRevision, nil } func (m *memStore) UpdateProjectAfterActivate(_ context.Context, projectID int64, workflowKey string, expectedVersion int, _ int64) error { m.mu.Lock() defer m.mu.Unlock() pm := m.ensureMeta(projectID) if pm.selectionVer != expectedVersion { return ErrConcurrentConflict } pm.lifecycle = LifecycleActive pm.activeKey = workflowKey pm.selectionVer++ m.meta[projectID] = pm return nil } func (m *memStore) BumpPlanRevision(_ context.Context, projectID int64, expectedPlanRevision int) (int, error) { m.mu.Lock() defer m.mu.Unlock() pm := m.ensureMeta(projectID) if pm.planRevision != expectedPlanRevision { return 0, ErrPlanRevisionConflict } pm.planRevision++ m.meta[projectID] = pm return pm.planRevision, nil } func (m *memStore) GetPlanningInstance(_ context.Context, projectID int64) (*WorkflowInstance, error) { m.mu.Lock() defer m.mu.Unlock() inst := m.plan[projectID] if inst == nil { return nil, nil } copyInst := *inst return ©Inst, nil } func (m *memStore) GetLiveInstance(_ context.Context, projectID int64) (*WorkflowInstance, error) { m.mu.Lock() defer m.mu.Unlock() inst := m.live[projectID] if inst == nil || !IsLiveInstanceStatus(inst.Status) { return nil, nil } copyInst := *inst return ©Inst, nil } func (m *memStore) GetGoverningInstanceForAccess(_ context.Context, projectID int64) (*WorkflowInstance, error) { m.mu.Lock() defer m.mu.Unlock() inst := m.live[projectID] if inst == nil { return nil, nil } if IsLiveInstanceStatus(inst.Status) || IsClosedInstanceStatus(inst.Status) { copyInst := *inst return ©Inst, nil } // planning 等非治理态:视为从未激活 return nil, nil } func (m *memStore) HasLiveInstance(_ context.Context, projectID int64) (bool, error) { m.mu.Lock() defer m.mu.Unlock() inst := m.live[projectID] return inst != nil && IsLiveInstanceStatus(inst.Status), nil } func (m *memStore) UpsertPlanningInstance(_ context.Context, inst *WorkflowInstance) (*WorkflowInstance, error) { m.mu.Lock() defer m.mu.Unlock() if inst.ID == 0 { inst.ID = int64(len(m.plan) + 1) } inst.Status = InstancePlanning now := time.Now() if inst.CreatedAt.IsZero() { inst.CreatedAt = now } inst.UpdatedAt = now copyInst := *inst m.plan[inst.ProjectID] = ©Inst m.rebuildStagesLocked(©Inst, false) return ©Inst, nil } func (m *memStore) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) { if _, err := m.ConsumeActivationToken(ctx, token, projectID, planHash); err != nil { return nil, err } return m.ActivateInstance(ctx, projectID, inst, version, expectedVersion, activatedBy) } func (m *memStore) CountActiveTaskRuns(_ context.Context, _ int64) (int, error) { return 0, nil } func (m *memStore) ActivateInstance(_ context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) { workflowKey := "" if version != nil { workflowKey = version.WorkflowKey } m.mu.Lock() defer m.mu.Unlock() pm := m.ensureMeta(projectID) if pm.selectionVer != expectedVersion { return nil, ErrConcurrentConflict } pm.lifecycle = LifecycleActive pm.activeKey = workflowKey pm.selectionVer++ m.meta[projectID] = pm now := time.Now() activated := *inst activated.Status = InstanceActive activated.ActivatedBy = &activatedBy activated.ActivatedAt = &now m.live[projectID] = &activated m.rebuildStagesLocked(&activated, true) delete(m.plan, projectID) return &activated, nil } func (m *memStore) GetInstanceByID(_ context.Context, instanceID int64) (*InstanceDetail, error) { m.mu.Lock() defer m.mu.Unlock() for _, inst := range m.live { if inst.ID == instanceID { return m.instanceDetailLocked(inst), nil } } for _, inst := range m.plan { if inst.ID == instanceID { return m.instanceDetailLocked(inst), nil } } return nil, ErrInstanceNotFound } func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey string, expectedRevision int) (*InstanceDetail, error) { m.mu.Lock() defer m.mu.Unlock() var inst *WorkflowInstance for _, live := range m.live { if live.ID == instanceID { inst = live break } } if inst == nil { return nil, ErrInstanceNotFound } if inst.ConfigRevision != expectedRevision { return nil, ErrInstanceRevisionConflict } records := m.stages[instanceID] activeIdx := -1 for i, rec := range records { if rec.stageKey == stageKey { if rec.status != StageActive { return nil, ErrStageNotActive } activeIdx = i break } } if activeIdx < 0 { return nil, ErrInvalidStage } records[activeIdx].status = StageCompleted nextIdx := -1 for i, rec := range records { if rec.enabled && rec.status == StagePending { nextIdx = i break } } inst.ConfigRevision++ inst.UpdatedAt = time.Now() if nextIdx < 0 { inst.Status = InstanceCompleted if pm := m.meta[inst.ProjectID]; pm.lifecycle != "" { pm.lifecycle = LifecyclePlanning pm.activeKey = "" m.meta[inst.ProjectID] = pm } } else { records[nextIdx].status = StageActive } m.stages[instanceID] = records copyInst := *inst m.live[inst.ProjectID] = ©Inst return m.instanceDetailLocked(©Inst), nil } func (m *memStore) MarkDocTaskApproved(_ context.Context, projectID int64, taskKey string) error { m.mu.Lock() defer m.mu.Unlock() live := m.live[projectID] if live == nil { return nil } records := m.stages[live.ID] for i := range records { for j := range records[i].tasks { if records[i].tasks[j].taskKey == taskKey && records[i].tasks[j].enabled { records[i].tasks[j].status = "approved" } } } m.stages[live.ID] = records return nil } func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) { m.mu.Lock() defer m.mu.Unlock() inst := m.live[projectID] if inst == nil || inst.ID != instanceID { return nil, ErrInstanceNotFound } now := time.Now() inst.Status = "terminated" copyInst := *inst delete(m.live, projectID) pm := m.ensureMeta(projectID) pm.lifecycle = LifecyclePlanning pm.activeKey = "" m.meta[projectID] = pm _ = terminatedBy _ = reason _ = now return ©Inst, nil } func (m *memStore) SaveActivationToken(_ context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, _ int64, expiresAt time.Time) error { m.mu.Lock() defer m.mu.Unlock() m.tokens[hashActivationToken(token)] = activationTokenRec{ projectID: projectID, planHash: planHash, expectedVer: expectedVersion, snapshot: snapshot, expiresAt: expiresAt, } return nil } func (m *memStore) ConsumeActivationToken(_ context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) { m.mu.Lock() defer m.mu.Unlock() tokenHash := hashActivationToken(token) rec, ok := m.tokens[tokenHash] if !ok || rec.used || rec.projectID != projectID || rec.planHash != planHash || time.Now().After(rec.expiresAt) { return nil, ErrTokenInvalid } rec.used = true m.tokens[tokenHash] = rec plan := rec.snapshot return &plan, nil } // SeedProjectMeta 供测试注入项目元数据。 func SeedProjectMeta(store Store, projectID int64, lifecycle, activeKey string, selectionVer, planRev int) error { ms, ok := store.(*memStore) if !ok { return fmt.Errorf("SeedProjectMeta only supported on memStore") } ms.mu.Lock() defer ms.mu.Unlock() ms.meta[projectID] = projectMeta{ lifecycle: lifecycle, activeKey: activeKey, selectionVer: selectionVer, planRevision: planRev, } return nil }