refactor(projectinstance): split store.go into use-case files

Move mem/pg activate/live/meta/plan/token/seed paths into ≤800-line
siblings without behavior change; short tests green.
This commit is contained in:
WeiCHEN
2026-09-23 09:54:17 +08:00
parent 9da7f352fa
commit 943becfb2a
8 changed files with 1481 additions and 1418 deletions
File diff suppressed because it is too large Load Diff
+579
View File
@@ -0,0 +1,579 @@
package projectinstance
import (
"context"
"fmt"
"sync"
"time"
"chat-agent-system/internal/workflowdef"
)
// =============================================================================
// 内存 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
policyRevision *int64
}
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 &copyInst, 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 &copyInst, 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 &copyInst, 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] = &copyInst
m.rebuildStagesLocked(&copyInst, false)
return &copyInst, nil
}
// SavePlanWithRevision 内存实现:bump 与 upsert 在同一互斥锁内,天然原子。
func (m *memStore) SavePlanWithRevision(ctx context.Context, inst *WorkflowInstance, expectedPlanRevision int) (*WorkflowInstance, int, error) {
newRev, err := m.BumpPlanRevision(ctx, inst.ProjectID, expectedPlanRevision)
if err != nil {
return nil, 0, err
}
saved, err := m.UpsertPlanningInstance(ctx, inst)
if err != nil {
return nil, 0, err
}
return saved, newRev, 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) {
usesAuthorOnly := planUsesAuthorOnly(inst.ConfigSnapshot)
var currentPolicyRev int64
if usesAuthorOnly {
snap, lockErr, ok := runActivateAuthorOnlyLock(ctx, nil)
if !ok {
return nil, ErrAuthorOnlyPolicyUnavailable
}
if lockErr != nil {
return nil, fmt.Errorf("%w: %v", ErrAuthorOnlyPolicyUnavailable, lockErr)
}
if !snap.Allowed {
return nil, ErrAuthorOnlyPolicyDisabled
}
currentPolicyRev = snap.Revision
}
if err := runActivateMemberRecheck(ctx); err != nil {
return nil, err
}
tokenPolicyRev, err := m.consumeActivationTokenLocked(ctx, token, projectID, planHash)
if err != nil {
return nil, err
}
if usesAuthorOnly {
if tokenPolicyRev == nil || *tokenPolicyRev != currentPolicyRev {
return nil, ErrAuthorOnlyPolicyChanged
}
}
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, _ int64) (*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
anotherActive := false
for i, rec := range records {
if i != activeIdx && rec.enabled && rec.status == StageActive {
anotherActive = true
break
}
}
nextIdx := -1
if !anotherActive {
for i, rec := range records {
if rec.enabled && rec.status == StagePending {
nextIdx = i
break
}
}
}
inst.ConfigRevision++
inst.UpdatedAt = time.Now()
if anotherActive {
inst.Status = InstanceActive
} else 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] = &copyInst
return m.instanceDetailLocked(&copyInst), nil
}
func (m *memStore) ReopenStage(_ context.Context, instanceID int64, stageKey string, docTaskKeys []string, _ int64) (*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
}
records := m.stages[instanceID]
idx := -1
for i, rec := range records {
if rec.stageKey == stageKey {
idx = i
break
}
}
if idx < 0 {
return nil, ErrInvalidStage
}
if records[idx].status != StageCompleted {
return nil, ErrStageNotCompleted
}
records[idx].status = StageActive
wanted := map[string]bool{}
for _, key := range docTaskKeys {
wanted[key] = true
}
for j := range records[idx].tasks {
if wanted[records[idx].tasks[j].taskKey] {
records[idx].tasks[j].status = "in_progress"
}
}
if inst.Status == InstanceCompleted {
inst.Status = InstanceActive
}
inst.ConfigRevision++
inst.UpdatedAt = time.Now()
m.stages[instanceID] = records
copyInst := *inst
m.live[inst.ProjectID] = &copyInst
return m.instanceDetailLocked(&copyInst), 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) UpdateLiveDocTaskDuties(_ context.Context, projectID int64, taskKey string, plan WorkflowPlan, _ int64) (*WorkflowInstance, error) {
m.mu.Lock()
defer m.mu.Unlock()
live := m.live[projectID]
if live == nil {
return nil, ErrInstanceNotFound
}
if findDocTaskPlan(plan, taskKey) == nil {
return nil, ErrDocTaskNotFound
}
planHash, err := computePlanHash(plan)
if err != nil {
return nil, err
}
copyInst := *live
copyInst.ConfigSnapshot = plan
copyInst.PlanHash = planHash
copyInst.ConfigRevision++
copyInst.UpdatedAt = time.Now()
m.live[projectID] = &copyInst
return &copyInst, nil
}
func (m *memStore) UpdateLivePlan(_ context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, _ int64) (*WorkflowInstance, error) {
m.mu.Lock()
defer m.mu.Unlock()
live := m.live[projectID]
if live == nil {
return nil, ErrInstanceNotFound
}
if live.ConfigRevision != expectedConfigRevision {
return nil, ErrInstanceRevisionConflict
}
planHash, err := computePlanHash(plan)
if err != nil {
return nil, err
}
copyInst := *live
copyInst.ConfigSnapshot = plan
copyInst.PlanHash = planHash
copyInst.ConfigRevision++
copyInst.UpdatedAt = time.Now()
m.live[projectID] = &copyInst
records := m.stages[copyInst.ID]
for i := range records {
for _, st := range plan.Stages {
if records[i].stageKey == st.StageKey {
records[i].enabled = st.Enabled
}
}
for j := range records[i].tasks {
dt := findDocTaskPlan(plan, records[i].tasks[j].taskKey)
if dt == nil {
continue
}
records[i].tasks[j].enabled = dt.Enabled
if !dt.Enabled && records[i].tasks[j].status == "pending" {
records[i].tasks[j].status = "skipped_disabled"
}
if dt.Enabled && records[i].tasks[j].status == "skipped_disabled" {
records[i].tasks[j].status = "pending"
}
}
}
m.stages[copyInst.ID] = records
return &copyInst, 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 = InstanceTerminated
copyInst := *inst
m.live[projectID] = &copyInst
pm := m.ensureMeta(projectID)
pm.lifecycle = LifecyclePlanning
pm.activeKey = ""
m.meta[projectID] = pm
_ = terminatedBy
_ = reason
_ = now
return &copyInst, nil
}
func (m *memStore) SaveActivationToken(_ context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, _ int64, expiresAt time.Time, policyRevision *int64) error {
m.mu.Lock()
defer m.mu.Unlock()
var revCopy *int64
if policyRevision != nil {
v := *policyRevision
revCopy = &v
}
m.tokens[hashActivationToken(token)] = activationTokenRec{
projectID: projectID, planHash: planHash, expectedVer: expectedVersion,
snapshot: snapshot, expiresAt: expiresAt, policyRevision: revCopy,
}
return nil
}
func (m *memStore) ConsumeActivationToken(ctx context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) {
_, plan, err := m.consumeActivationTokenWithRev(ctx, token, projectID, planHash)
return plan, err
}
func (m *memStore) consumeActivationTokenLocked(ctx context.Context, token string, projectID int64, planHash string) (*int64, error) {
rev, _, err := m.consumeActivationTokenWithRev(ctx, token, projectID, planHash)
return rev, err
}
func (m *memStore) consumeActivationTokenWithRev(_ context.Context, token string, projectID int64, planHash string) (*int64, *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, nil, ErrTokenInvalid
}
rec.used = true
m.tokens[tokenHash] = rec
plan := rec.snapshot
return rec.policyRevision, &plan, nil
}
@@ -0,0 +1,233 @@
package projectinstance
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"chat-agent-system/internal/workflowdef"
)
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)
usesAuthorOnly := planUsesAuthorOnly(inst.ConfigSnapshot)
var currentPolicyRev int64
if usesAuthorOnly {
snap, lockErr, ok := runActivateAuthorOnlyLock(ctx, tx)
if !ok {
return nil, ErrAuthorOnlyPolicyUnavailable
}
if lockErr != nil {
return nil, fmt.Errorf("%w: %v", ErrAuthorOnlyPolicyUnavailable, lockErr)
}
if !snap.Allowed {
return nil, ErrAuthorOnlyPolicyDisabled
}
currentPolicyRev = snap.Revision
}
if err := runActivateMemberRecheck(ctx); err != nil {
return nil, err
}
if token != "" {
tokenPolicyRev, err := consumeActivationTokenTx(ctx, tx, token, projectID, planHash)
if err != nil {
return nil, err
}
if usesAuthorOnly {
if tokenPolicyRev == nil || *tokenPolicyRev != currentPolicyRev {
return nil, ErrAuthorOnlyPolicyChanged
}
}
}
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) (*int64, error) {
tokenHash := hashActivationToken(token)
var storedHash string
var usedAt *time.Time
var expiresAt time.Time
var policyRev *int64
err := tx.QueryRow(ctx, `
SELECT plan_hash, used_at, expires_at, author_only_policy_revision
FROM workflow_activation_tokens WHERE token = $1 AND project_id = $2
`, tokenHash, projectID).Scan(&storedHash, &usedAt, &expiresAt, &policyRev)
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 := tx.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
}
return policyRev, 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
}
+248
View File
@@ -0,0 +1,248 @@
package projectinstance
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
"chat-agent-system/internal/taskrun"
)
func (s *pgStore) UpdateLiveDocTaskDuties(ctx context.Context, projectID int64, taskKey string, plan WorkflowPlan, assignedBy int64) (*WorkflowInstance, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
live, err := s.GetLiveInstance(ctx, projectID)
if err != nil {
return nil, err
}
if live == nil {
return nil, ErrInstanceNotFound
}
planHash, err := computePlanHash(plan)
if err != nil {
return nil, err
}
snapRaw, err := json.Marshal(plan)
if err != nil {
return nil, err
}
now := time.Now()
row := tx.QueryRow(ctx, `
UPDATE project_workflow_instances SET
config_snapshot = $2, plan_hash = $3, config_revision = config_revision + 1, updated_at = $4
WHERE id = $1 AND status IN ('active', 'awaiting_stage_confirmation')
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
`, live.ID, snapRaw, planHash, now)
inst, err := scanInstance(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrInstanceNotFound
}
if err != nil {
return nil, err
}
var docTaskID int64
err = tx.QueryRow(ctx, `
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 AND dti.task_key = $2
`, inst.ID, taskKey).Scan(&docTaskID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrDocTaskNotFound
}
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `DELETE FROM document_duty_assignments WHERE doc_task_instance_id = $1`, docTaskID); err != nil {
return nil, err
}
dt := findDocTaskPlan(plan, taskKey)
if dt == nil {
return nil, ErrDocTaskNotFound
}
mode, effective, _ := ResolveDocTaskDuty(*dt)
if mode != DutyModeNone {
for role, uid := range map[string]int64{
DutyAuthor: effective.AuthorUserID, DutyReviewer: effective.ReviewerUserID, DutyApprover: effective.ApproverUserID,
} {
if uid <= 0 {
continue
}
if _, 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, assignedBy); err != nil {
return nil, err
}
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return inst, nil
}
func (s *pgStore) UpdateLivePlan(ctx context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, assignedBy int64) (*WorkflowInstance, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var instanceID int64
var revision int
err = tx.QueryRow(ctx, `
SELECT id, config_revision
FROM project_workflow_instances
WHERE project_id = $1 AND status IN ('active', 'awaiting_stage_confirmation')
FOR UPDATE
`, projectID).Scan(&instanceID, &revision)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrInstanceNotFound
}
if err != nil {
return nil, err
}
if revision != expectedConfigRevision {
return nil, ErrInstanceRevisionConflict
}
planHash, err := computePlanHash(plan)
if err != nil {
return nil, err
}
snapRaw, err := json.Marshal(plan)
if err != nil {
return nil, err
}
now := time.Now()
row := tx.QueryRow(ctx, `
UPDATE project_workflow_instances SET
config_snapshot = $2, plan_hash = $3, config_revision = config_revision + 1, updated_at = $4
WHERE id = $1 AND config_revision = $5
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, snapRaw, planHash, now, expectedConfigRevision)
inst, err := scanInstance(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrInstanceRevisionConflict
}
if err != nil {
return nil, err
}
for _, st := range plan.Stages {
if _, err := tx.Exec(ctx, `
UPDATE project_stage_instances
SET enabled = $3, updated_at = $4
WHERE workflow_instance_id = $1 AND stage_key = $2
`, inst.ID, st.StageKey, st.Enabled, now); err != nil {
return nil, err
}
for _, dt := range st.DocTasks {
var docTaskID int64
err = tx.QueryRow(ctx, `
UPDATE project_doc_task_instances AS dti SET
enabled = $3,
status = CASE
WHEN $3 = false AND dti.status = 'pending' THEN 'skipped_disabled'
WHEN $3 = true AND dti.status = 'skipped_disabled' THEN 'pending'
ELSE dti.status
END,
updated_at = $4
FROM project_stage_instances si
WHERE dti.stage_instance_id = si.id
AND si.workflow_instance_id = $1
AND dti.task_key = $2
RETURNING dti.id
`, inst.ID, dt.TaskKey, dt.Enabled, now).Scan(&docTaskID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrDocTaskNotFound
}
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `DELETE FROM document_duty_assignments WHERE doc_task_instance_id = $1`, docTaskID); err != nil {
return nil, err
}
if !dt.Enabled || !st.Enabled {
continue
}
mode, effective, _ := ResolveDocTaskDuty(dt)
if mode == DutyModeNone {
continue
}
for role, uid := range map[string]int64{
DutyAuthor: effective.AuthorUserID, DutyReviewer: effective.ReviewerUserID, DutyApprover: effective.ApproverUserID,
} {
if uid <= 0 {
continue
}
if _, 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, assignedBy); err != nil {
return nil, err
}
}
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return inst, 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
}
if err := taskrun.CancelOpenByProject(ctx, tx, projectID, now, "workflow terminated"); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return inst, nil
}
+114
View File
@@ -0,0 +1,114 @@
package projectinstance
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
)
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
}
+227
View File
@@ -0,0 +1,227 @@
package projectinstance
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
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) {
// Allocate next instance_no so planning drafts after a completed main/rdev
// instance do not collide on (project_id, instance_no).
var nextNo int
if err := tx.QueryRow(ctx, `
SELECT COALESCE(MAX(instance_no), 0) + 1
FROM project_workflow_instances
WHERE project_id = $1
`, inst.ProjectID).Scan(&nextNo); err != nil {
return nil, err
}
inst.InstanceNo = nextNo
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, $3, 'planning', $4, $5, $6, $7, $8, $9)
RETURNING id
`, inst.ProjectID, inst.WorkflowVersionID, nextNo, 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
}
// SavePlanWithRevision 在单事务内完成 planning 实例 upsert 与 plan revision bump。
// bump 先执行(带期望版本乐观锁),失败则整个事务回滚,保证 plan 内容与版本号原子一致。
func (s *pgStore) SavePlanWithRevision(ctx context.Context, inst *WorkflowInstance, expectedPlanRevision int) (*WorkflowInstance, int, 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, 0, err
}
defer tx.Rollback(ctx)
// 先 bump revision(乐观锁),冲突即回滚,不写 plan
var newRev int
err = tx.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
`, inst.ProjectID, expectedPlanRevision).Scan(&newRev)
if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, ErrPlanRevisionConflict
}
if err != nil {
return nil, 0, err
}
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) {
var nextNo int
if err := tx.QueryRow(ctx, `
SELECT COALESCE(MAX(instance_no), 0) + 1
FROM project_workflow_instances
WHERE project_id = $1
`, inst.ProjectID).Scan(&nextNo); err != nil {
return nil, 0, err
}
inst.InstanceNo = nextNo
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, $3, 'planning', $4, $5, $6, $7, $8, $9)
RETURNING id
`, inst.ProjectID, inst.WorkflowVersionID, nextNo, inst.ConfigRevision, inst.PlanHash, snapRaw,
inst.CreatedBy, inst.CreatedAt, inst.UpdatedAt).Scan(&inst.ID)
if err != nil {
return nil, 0, err
}
} else if err != nil {
return nil, 0, 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, 0, err
}
}
if err := s.replaceInstanceChildren(ctx, tx, inst); err != nil {
return nil, 0, err
}
if err := tx.Commit(ctx); err != nil {
return nil, 0, err
}
return inst, newRev, 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
}
mode, effective, _ := ResolveDocTaskDuty(dt)
if mode == DutyModeNone {
continue
}
for role, uid := range map[string]int64{
DutyAuthor: effective.AuthorUserID, DutyReviewer: effective.ReviewerUserID, DutyApprover: effective.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
}
@@ -0,0 +1,60 @@
package projectinstance
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
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, policyRevision *int64) 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, author_only_policy_revision)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, tokenHash, projectID, planHash, expectedVersion, raw, createdBy, expiresAt, policyRevision)
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
}
+20
View File
@@ -0,0 +1,20 @@
package projectinstance
import (
"fmt"
)
// 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
}