merge: sync origin/dev into local dev (closeout batch)

Resolve conflicts: both CURRENT claim tables (slot preserved), local
multi-host coordination plan + origin phased companion, origin
module-boundaries, combined PG isolation with remote product tests.
FEATURE-MATRIX takes origin record_id layout; A-G done bump deferred
pending evidence row apply.
This commit is contained in:
weichen
2026-09-23 12:45:30 +00:00
607 changed files with 55151 additions and 13921 deletions
@@ -29,8 +29,8 @@ func TestPG_GoverningAccess_NeverActiveUnrestricted(t *testing.T) {
code := "pg-access-none-" + time.Now().Format("150405.000")
err = pool.QueryRow(ctx, `
INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision)
VALUES ($1, 'Access Never Active', 'active', $2, 'planning', '', 1, 0) RETURNING id
`, code, creatorID).Scan(&projectID)
VALUES ($1, $2, 'active', $3, 'planning', '', 1, 0) RETURNING id
`, code, "Access Never Active "+code, creatorID).Scan(&projectID)
if err != nil {
t.Fatalf("insert project: %v", err)
}
@@ -40,8 +40,8 @@ func TestPG_ActivateOfficialWorkflowRuntimeMetadata(t *testing.T) {
code := "pg-official-" + time.Now().Format("150405.000")
err = pool.QueryRow(ctx, `
INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision)
VALUES ($1, 'PG Official Activation Test', 'active', 1, 'planning', '', 1, 0) RETURNING id
`, code).Scan(&projectID)
VALUES ($1, $2, 'active', 1, 'planning', '', 1, 0) RETURNING id
`, code, "PG Official Activation Test "+code).Scan(&projectID)
if err != nil {
t.Fatalf("insert project: %v", err)
}
+75 -23
View File
@@ -9,6 +9,7 @@ import (
"chat-agent-system/internal/db"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/workflowdef"
@@ -50,19 +51,38 @@ func TestPG_ActivationAndStageAdvance(t *testing.T) {
ctx := context.Background()
// 治理链路仅接受官方工作流(BuildDefaultPlan official-only),seed 官方 main manifest。
def, err := workflowdef.LoadManifestDefinition(workflowdef.V2ManifestFiles[workflowdef.OfficialKeyMain])
wfKey := workflowdef.OfficialKeyMain
def, err := workflowdef.LoadManifestDefinition(workflowdef.V2ManifestFiles[wfKey])
if err != nil {
t.Fatalf("load manifest: %v", err)
}
wfStore := workflowdef.NewStore(pool)
if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{
Key: workflowdef.OfficialKeyMain,
Name: workflowdef.OfficialWorkflowName(workflowdef.OfficialKeyMain),
Key: wfKey,
Name: workflowdef.OfficialWorkflowName(wfKey),
Definition: def,
}, 1); err != nil {
t.Fatalf("seed official workflow: %v", err)
}
wfKey := workflowdef.OfficialKeyMain
var authorID, reviewerID, approverID int64
rows, err := pool.Query(ctx, `SELECT id FROM users ORDER BY id ASC LIMIT 3`)
if err != nil {
t.Fatalf("list users: %v", err)
}
defer rows.Close()
ids := make([]int64, 0, 3)
for rows.Next() {
var id int64
if scanErr := rows.Scan(&id); scanErr != nil {
t.Fatalf("scan user: %v", scanErr)
}
ids = append(ids, id)
}
if len(ids) < 3 {
t.Skip("need at least 3 user rows for duty assignees")
}
authorID, reviewerID, approverID = ids[0], ids[1], ids[2]
pwStore := projectworkflow.NewStore(pool)
instStore := projectinstance.NewStore(pool)
@@ -73,8 +93,8 @@ func TestPG_ActivationAndStageAdvance(t *testing.T) {
code := "pg-p3-" + time.Now().Format("150405.000000")
err = pool.QueryRow(ctx, `
INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision)
VALUES ($1, 'PG Phase3 Test ' || $1, 'active', 2, 'planning', '', 1, 0) RETURNING id
`, code).Scan(&projectID)
VALUES ($1, $2, 'active', $3, 'planning', '', 1, 0) RETURNING id
`, code, "PG Phase3 Test "+code, authorID).Scan(&projectID)
if err != nil {
t.Fatalf("insert project: %v", err)
}
@@ -86,28 +106,37 @@ func TestPG_ActivationAndStageAdvance(t *testing.T) {
if err != nil {
t.Fatalf("default plan: %v", err)
}
intakeTask := ""
for i := range plan.Stages {
for j := range plan.Stages[i].DocTasks {
if plan.Stages[i].DocTasks[j].TaskKey == "bd_intake" {
plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{
AuthorUserID: 2, ReviewerUserID: 3, ApproverUserID: 1,
}
tk := plan.Stages[i].DocTasks[j].TaskKey
plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{
AuthorUserID: authorID, ReviewerUserID: reviewerID, ApproverUserID: approverID,
}
if tk == "intake" || tk == "bd_intake" {
intakeTask = tk
} else {
// Keep a single blocking intake path so stage readiness is deterministic.
plan.Stages[i].DocTasks[j].Enabled = false
}
}
}
if intakeTask == "" {
t.Fatal("official main plan missing intake/bd_intake task")
}
_, err = svc.SaveWorkflowPlan(ctx, projectID, 2, true, projectinstance.SavePlanInput{
_, err = svc.SaveWorkflowPlan(ctx, projectID, authorID, true, projectinstance.SavePlanInput{
Plan: *plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
})
if err != nil {
t.Fatalf("save plan: %v", err)
}
preview, err := svc.ActivationPreview(ctx, projectID, 2, true)
preview, err := svc.ActivationPreview(ctx, projectID, authorID, true)
if err != nil {
t.Fatalf("preview: %v", err)
}
activated, err := svc.Activate(ctx, projectID, 2, true, projectinstance.ActivateInput{
ConfirmationToken: preview.ConfirmationToken,
activated, err := svc.Activate(ctx, projectID, authorID, true, projectinstance.ActivateInput{
ConfirmationToken: preview.ConfirmationToken,
ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
@@ -117,19 +146,19 @@ func TestPG_ActivationAndStageAdvance(t *testing.T) {
docID := "doc_pg_p3_intake_" + code
engine := docgov.NewGovernanceEngine(pool)
_, err = engine.CreateOrGetDocument(ctx, docID, projectID, "bd_intake", "用户初始需求收集表", "V0.1", "", 2, "张", 3, "李", 1, "王")
_, err = engine.CreateOrGetDocument(ctx, docID, projectID, intakeTask, "客户需求采集", "V0.1", "", authorID, "张", reviewerID, "李", approverID, "王")
if err != nil {
t.Fatalf("create doc: %v", err)
}
_ = engine.MarkDocumentContentUpdated(ctx, docID, "hash_pg_p3", false)
_, _ = engine.SubmitForReview(ctx, docID, 2, "author", "张")
_, _ = engine.ReviewApprove(ctx, docID, 3, "reviewer", "李", "ok")
_, err = engine.ApproverApprove(ctx, docID, 1, "approver", "王", "ok")
_, _ = engine.SubmitForReview(ctx, docID, authorID, "author", "张")
_, _ = engine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "李", "ok")
_, err = engine.ApproverApprove(ctx, docID, approverID, "approver", "王", "ok")
if err != nil {
t.Fatalf("approve doc: %v", err)
}
approvedResult, err := svc.HandleDocumentApproved(ctx, projectID, "bd_intake")
approvedResult, err := svc.HandleDocumentApproved(ctx, projectID, intakeTask)
if err != nil {
t.Fatalf("handle document approved: %v", err)
}
@@ -137,7 +166,31 @@ func TestPG_ActivationAndStageAdvance(t *testing.T) {
t.Fatalf("expected stage ready after approval, got %+v", approvedResult)
}
readiness, err := svc.GetStageReadiness(ctx, activated.Instance.ID, "V0.1_triage", 2, true)
notifySvc := notification.NewService(pool)
if notifySvc.Available() {
created, emitErr := notifySvc.EmitStageReadyToAdvance(ctx, authorID, notification.StageReadyPayload{
ProjectID: projectID,
WorkflowInstanceID: approvedResult.InstanceID,
StageInstanceID: approvedResult.StageInstanceID,
StageKey: approvedResult.StageKey,
StageName: approvedResult.StageName,
ConfigRevision: approvedResult.ConfigRevision,
RequiredDocCount: approvedResult.RequiredDocCount,
ApprovedDocCount: approvedResult.ApprovedDocCount,
})
if emitErr != nil {
t.Fatalf("emit stage ready notification: %v", emitErr)
}
if !created {
t.Fatal("expected stage ready notification to be created")
}
}
stageKey := approvedResult.StageKey
if stageKey == "" {
stageKey = "V0.1_triage"
}
readiness, err := svc.GetStageReadiness(ctx, activated.Instance.ID, stageKey, authorID, true)
if err != nil {
t.Fatalf("readiness: %v", err)
}
@@ -151,8 +204,8 @@ func TestPG_ActivationAndStageAdvance(t *testing.T) {
t.Fatalf("expected ready stage, pending=%v issues=%+v", otherTasks, readiness.BlockingIssues)
}
advanced, err := svc.AdvanceStage(ctx, activated.Instance.ID, 2, true, projectinstance.AdvanceStageInput{
StageKey: "V0.1_triage", ExpectedConfigRevision: activated.Instance.ConfigRevision,
advanced, err := svc.AdvanceStage(ctx, activated.Instance.ID, authorID, true, projectinstance.AdvanceStageInput{
StageKey: stageKey, ExpectedConfigRevision: activated.Instance.ConfigRevision,
})
if err != nil {
t.Fatalf("advance: %v", err)
@@ -161,4 +214,3 @@ func TestPG_ActivationAndStageAdvance(t *testing.T) {
t.Fatalf("expected next stage or done, got %+v", advanced)
}
}
+16
View File
@@ -0,0 +1,16 @@
package projectinstance
import (
"context"
"chat-agent-system/internal/workflowdef"
)
// VersionResolver is the projectinstance-owned port for reading published workflow definitions.
// Concrete workflowdef.Store is injected at the composition root; production code must not
// construct workflowdef services directly.
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)
}
+2 -2
View File
@@ -37,8 +37,8 @@ func TestPG_RDevActivateWithNAClose(t *testing.T) {
code := "pg-rdev-" + time.Now().Format("150405.000")
err = pool.QueryRow(ctx, `
INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision)
VALUES ($1, 'PG RDev Activation', 'active', 1, 'planning', '', 1, 0) RETURNING id
`, code).Scan(&projectID)
VALUES ($1, $2, 'active', 1, 'planning', '', 1, 0) RETURNING id
`, code, "PG RDev Activation "+code).Scan(&projectID)
if err != nil {
t.Fatalf("insert project: %v", err)
}
-7
View File
@@ -11,13 +11,6 @@ import (
"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
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
}