Files
Agent-System/internal/docgov/materialize.go
T
weichenandCursor b74441fb4f fix(docgov): materialize enabled workflow docs as template records
Activation and list healing need a server-side document row per enabled task, without overwriting documents that already have content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 05:20:01 +00:00

158 lines
5.4 KiB
Go

package docgov
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
// TemplateDocSpec 是激活后要落到 document_records 的受控文档槽。
type TemplateDocSpec struct {
TaskKey string
Title string
StageKey string
DutyPolicy string
Enabled bool
AuthorUserID int64
ReviewerUserID int64
ApproverUserID int64
AuthorName string
ReviewerName string
ApproverName string
}
// ControlledDocID 是项目受控文档的权威 doc_id。
func ControlledDocID(projectID int64, taskKey string) string {
return fmt.Sprintf("doc_proj%d_%s", projectID, strings.TrimSpace(taskKey))
}
// MaterializeTemplateDocs 为已启用的文档任务幂等写入模板台账。
// 已脱离模板(is_template_only=false)的正文与职责不被覆盖。
func (e *GovernanceEngine) MaterializeTemplateDocs(ctx context.Context, projectID int64, specs []TemplateDocSpec) error {
if e == nil || projectID <= 0 {
return nil
}
for _, spec := range specs {
if !spec.Enabled || strings.TrimSpace(spec.TaskKey) == "" {
continue
}
if err := e.upsertTemplateDoc(ctx, projectID, spec); err != nil {
return err
}
}
return nil
}
func (e *GovernanceEngine) upsertTemplateDoc(ctx context.Context, projectID int64, spec TemplateDocSpec) error {
taskKey := strings.TrimSpace(spec.TaskKey)
docID := ControlledDocID(projectID, taskKey)
title := strings.TrimSpace(spec.Title)
if title == "" {
title = taskKey
}
authorName := dutyDisplayName(spec.AuthorUserID, spec.AuthorName)
reviewerName := dutyDisplayName(spec.ReviewerUserID, spec.ReviewerName)
approverName := dutyDisplayName(spec.ApproverUserID, spec.ApproverName)
policy := strings.TrimSpace(spec.DutyPolicy)
now := time.Now()
if e.pool != nil {
cfgJSON, err := json.Marshal(DefaultDocumentConfig())
if err != nil {
return fmt.Errorf("docgov: marshal template config: %w", err)
}
_, err = e.pool.Exec(ctx, `
INSERT INTO document_records (
doc_id, project_id, task_key, title, stage, version,
current_state, is_locked, is_template_only, work_dir,
author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name,
config, duty_locked, duty_policy, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, 1,
$6, false, true, '',
$7, $8, $9, $10, $11, $12,
$13, true, $14, $15, $15
)
ON CONFLICT (doc_id) DO UPDATE SET
title = CASE WHEN document_records.is_template_only THEN EXCLUDED.title ELSE document_records.title END,
stage = CASE WHEN document_records.is_template_only THEN EXCLUDED.stage ELSE document_records.stage END,
author_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.author_id ELSE document_records.author_id END,
author_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.author_name ELSE document_records.author_name END,
reviewer_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.reviewer_id ELSE document_records.reviewer_id END,
reviewer_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.reviewer_name ELSE document_records.reviewer_name END,
approver_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.approver_id ELSE document_records.approver_id END,
approver_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.approver_name ELSE document_records.approver_name END,
duty_policy = CASE WHEN document_records.is_template_only THEN EXCLUDED.duty_policy ELSE document_records.duty_policy END,
duty_locked = CASE WHEN document_records.is_template_only THEN TRUE ELSE document_records.duty_locked END,
updated_at = EXCLUDED.updated_at
`, docID, projectID, taskKey, title, strings.TrimSpace(spec.StageKey),
string(StateDrafting),
nullIfZero(spec.AuthorUserID), authorName,
nullIfZero(spec.ReviewerUserID), reviewerName,
nullIfZero(spec.ApproverUserID), approverName,
cfgJSON, policy, now)
if err != nil {
return fmt.Errorf("docgov: materialize template doc %s: %w", docID, err)
}
}
e.mu.Lock()
defer e.mu.Unlock()
if existing, ok := e.memDocs[docID]; ok && existing != nil {
if !existing.IsTemplateOnly {
return nil
}
existing.Title = title
existing.Stage = strings.TrimSpace(spec.StageKey)
existing.TaskKey = taskKey
existing.AuthorID = spec.AuthorUserID
existing.AuthorName = authorName
existing.ReviewerID = spec.ReviewerUserID
existing.ReviewerName = reviewerName
existing.ApproverID = spec.ApproverUserID
existing.ApproverName = approverName
existing.DutyLocked = true
existing.DutyPolicy = policy
existing.UpdatedAt = now
return nil
}
if e.memDocs == nil {
e.memDocs = map[string]*DocumentRecord{}
}
e.memDocs[docID] = &DocumentRecord{
DocID: docID,
ProjectID: projectID,
TaskKey: taskKey,
Title: title,
Stage: strings.TrimSpace(spec.StageKey),
Version: 1,
CurrentState: StateDrafting,
IsTemplateOnly: true,
AuthorID: spec.AuthorUserID,
AuthorName: authorName,
ReviewerID: spec.ReviewerUserID,
ReviewerName: reviewerName,
ApproverID: spec.ApproverUserID,
ApproverName: approverName,
DutyLocked: true,
DutyPolicy: policy,
Config: DefaultDocumentConfig(),
CreatedAt: now,
UpdatedAt: now,
}
return nil
}
func dutyDisplayName(userID int64, name string) string {
name = strings.TrimSpace(name)
if name != "" {
return name
}
if userID > 0 {
return fmt.Sprintf("user-%d", userID)
}
return ""
}