feat(projectinstance): refresh stage readiness after document approval.

Sync doc task status on approve and evaluate whether to emit stage advance readiness; chain docgov hooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 02:50:23 +00:00
committed by weichen
co-authored by Cursor
parent 10fa91de61
commit 32b88beee0
6 changed files with 222 additions and 7 deletions
+33 -7
View File
@@ -30,7 +30,8 @@ type GovernanceEngine struct {
memComms map[string][]*DocumentComment
memProps map[string]*ChangeProposal
memAudits map[string][]*AuditLog
onApprovedHook func(ctx context.Context, doc *DocumentRecord)
onApprovedHook func(ctx context.Context, doc *DocumentRecord)
onApprovedHooks []func(ctx context.Context, doc *DocumentRecord)
}
// SetOnApprovedHook 注册文档终审批准时的回调钩子
@@ -40,6 +41,34 @@ func (e *GovernanceEngine) SetOnApprovedHook(hook func(ctx context.Context, doc
e.onApprovedHook = hook
}
// AddOnApprovedHook 追加文档终审批准回调(与 SetOnApprovedHook 并存)。
func (e *GovernanceEngine) AddOnApprovedHook(hook func(ctx context.Context, doc *DocumentRecord)) {
if hook == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
e.onApprovedHooks = append(e.onApprovedHooks, hook)
}
func (e *GovernanceEngine) runApprovedHooks(ctx context.Context, doc *DocumentRecord) {
var primary func(context.Context, *DocumentRecord)
var extra []func(context.Context, *DocumentRecord)
e.mu.Lock()
primary = e.onApprovedHook
extra = append([]func(context.Context, *DocumentRecord){}, e.onApprovedHooks...)
e.mu.Unlock()
if primary != nil {
primary(ctx, doc)
}
for _, hook := range extra {
if hook != nil {
hook(ctx, doc)
}
}
}
// NewGovernanceEngine 初始化治理引擎
func NewGovernanceEngine(pool *pgxpool.Pool) *GovernanceEngine {
return &GovernanceEngine{
@@ -821,12 +850,9 @@ func (e *GovernanceEngine) ApproverApprove(ctx context.Context, docID string, ac
e.memAudits[docID] = append(e.memAudits[docID], audit)
e.syncDocAndAuditToDB(ctx, doc, audit)
if e.onApprovedHook != nil {
hook := e.onApprovedHook
e.mu.Unlock()
hook(ctx, doc)
e.mu.Lock()
}
e.mu.Unlock()
e.runApprovedHooks(ctx, doc)
e.mu.Lock()
return doc, nil
}
+78
View File
@@ -0,0 +1,78 @@
package projectinstance
import "context"
// DocumentApprovedResult 文档批准后阶段就绪评估结果。
type DocumentApprovedResult struct {
StageReady bool
InstanceID int64
StageInstanceID int64
StageKey string
StageName string
ConfigRevision int
RequiredDocCount int
ApprovedDocCount int
}
// HandleDocumentApproved 同步文档任务状态并评估是否首次满足阶段推进条件。
func (s *Service) HandleDocumentApproved(ctx context.Context, projectID int64, taskKey string) (*DocumentApprovedResult, error) {
out := &DocumentApprovedResult{}
if s == nil || !s.Available() || projectID <= 0 || taskKey == "" {
return out, nil
}
if err := s.store.MarkDocTaskApproved(ctx, projectID, taskKey); err != nil {
return nil, err
}
live, err := s.store.GetLiveInstance(ctx, projectID)
if err != nil || live == nil {
return out, err
}
detail, err := s.store.GetInstanceByID(ctx, live.ID)
if err != nil || detail == nil || detail.ActiveStageKey == "" {
return out, err
}
readiness, err := s.GetStageReadiness(ctx, live.ID, detail.ActiveStageKey, 0, true)
if err != nil {
return nil, err
}
if !readiness.Ready {
return out, nil
}
var stageInstID int64
for _, st := range detail.Stages {
if st.StageKey == detail.ActiveStageKey {
stageInstID = st.ID
break
}
}
if stageInstID <= 0 {
return out, nil
}
required := 0
approved := 0
for _, dt := range readiness.DocTasks {
if !dt.Enabled || !dt.Required {
continue
}
required++
if dt.Approved {
approved++
}
}
out.StageReady = true
out.InstanceID = live.ID
out.StageInstanceID = stageInstID
out.StageKey = readiness.StageKey
out.StageName = readiness.StageName
out.ConfigRevision = readiness.ConfigRevision
out.RequiredDocCount = required
out.ApprovedDocCount = approved
return out, nil
}
+44
View File
@@ -0,0 +1,44 @@
package projectinstance
import (
"context"
"testing"
)
func TestHandleDocumentApproved_StageReady(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 1, LifecycleActive, "medical_device_development", 1, 1)
svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}})
plan := WorkflowPlan{
WorkflowDefinitionKey: "medical_device_development",
WorkflowVersion: 1,
Stages: []StagePlan{{
StageKey: "biz_intake", Enabled: true,
DocTasks: []DocTaskPlan{{
TaskKey: "intake", Enabled: true,
Duties: DocDuty{AuthorUserID: 2, ReviewerUserID: 3, ApproverUserID: 4},
}},
}},
}
inst := &WorkflowInstance{
ID: 1, ProjectID: 1, WorkflowVersionID: 10, Status: InstanceActive,
ConfigRevision: 1, PlanHash: "h", ConfigSnapshot: plan, InstanceNo: 1,
}
activated, err := store.ActivateInstance(ctx, 1, inst, "medical_device_development", 1, 2)
if err != nil {
t.Fatalf("activate: %v", err)
}
result, err := svc.HandleDocumentApproved(ctx, 1, "intake")
if err != nil {
t.Fatalf("handle approved: %v", err)
}
if result == nil || !result.StageReady {
t.Fatalf("expected stage ready, got %+v", result)
}
if result.InstanceID != activated.ID || result.StageKey != "biz_intake" {
t.Fatalf("unexpected result: %+v", result)
}
}
@@ -10,6 +10,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"
@@ -140,6 +141,34 @@ func TestPG_ActivationAndStageAdvance(t *testing.T) {
t.Fatalf("approve doc: %v", err)
}
approvedResult, err := svc.HandleDocumentApproved(ctx, projectID, "intake")
if err != nil {
t.Fatalf("handle document approved: %v", err)
}
if approvedResult == nil || !approvedResult.StageReady {
t.Fatalf("expected stage ready after approval, got %+v", approvedResult)
}
notifySvc := notification.NewService(pool)
if notifySvc.Available() {
created, emitErr := notifySvc.EmitStageReadyToAdvance(ctx, 2, 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")
}
}
readiness, err := svc.GetStageReadiness(ctx, activated.Instance.ID, "biz_intake", 2, true)
if err != nil {
t.Fatalf("readiness: %v", err)
+18
View File
@@ -172,3 +172,21 @@ func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey s
}
return s.loadInstanceDetail(ctx, inst)
}
func (s *pgStore) MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error {
if projectID <= 0 || taskKey == "" {
return nil
}
_, err := s.pool.Exec(ctx, `
UPDATE project_doc_task_instances dti
SET status = 'approved', updated_at = now()
FROM project_stage_instances psi
JOIN project_workflow_instances pwi ON pwi.id = psi.workflow_instance_id
WHERE dti.stage_instance_id = psi.id
AND pwi.project_id = $1
AND dti.task_key = $2
AND dti.enabled = true
AND dti.status <> 'skipped'
`, projectID, taskKey)
return err
}
+20
View File
@@ -44,6 +44,7 @@ type Store interface {
GetInstanceByID(ctx context.Context, instanceID int64) (*InstanceDetail, error)
AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int) (*InstanceDetail, error)
MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error
}
// NewStore 构建 PG 或内存 Store。
@@ -672,6 +673,25 @@ func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey st
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) SaveActivationToken(_ context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, _ int64, expiresAt time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()