Files
Agent-System/internal/docgov/project_docs.go
T
WeiCHENandCursor 68765023a2 feat(api): add workflow document list, content read, and backfill script
Expose project controlled documents from DocGov, serve authenticated
output.md content by doc_id, and add idempotent backfill for run_1789111889557885000.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 02:50:22 +00:00

133 lines
3.9 KiB
Go

package docgov
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// WorkflowDocumentSummary 项目工作流受控文档列表项。
type WorkflowDocumentSummary struct {
DocID string `json:"doc_id"`
ProjectID int64 `json:"project_id"`
TaskKey string `json:"task_key"`
Title string `json:"title"`
LifecycleState DocumentState `json:"lifecycle_state"`
IsTemplateOnly bool `json:"is_template_only"`
ContentSHA256 string `json:"content_sha256"`
WorkDir string `json:"work_dir,omitempty"`
Version int `json:"version"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// DocumentContent 受控文档正文读取结果。
type DocumentContent struct {
DocID string `json:"doc_id"`
Version int `json:"version"`
ContentSHA256 string `json:"content_sha256"`
Markdown string `json:"markdown"`
IsTemplateOnly bool `json:"is_template_only"`
}
// ListWorkflowDocumentsByProject 列出项目下工作流受控文档(task_key 非空)。
func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, projectID int64) ([]WorkflowDocumentSummary, error) {
if projectID <= 0 {
return nil, fmt.Errorf("invalid project_id")
}
if e.pool != nil {
rows, err := e.pool.Query(ctx, `
SELECT doc_id, project_id, task_key, title, current_state, is_template_only,
COALESCE(content_sha256, ''), COALESCE(work_dir, ''), version, updated_at
FROM document_records
WHERE project_id = $1 AND task_key <> ''
ORDER BY task_key ASC, doc_id ASC
`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]WorkflowDocumentSummary, 0)
for rows.Next() {
var item WorkflowDocumentSummary
var state string
var updatedAt time.Time
if err := rows.Scan(
&item.DocID, &item.ProjectID, &item.TaskKey, &item.Title, &state,
&item.IsTemplateOnly, &item.ContentSHA256, &item.WorkDir, &item.Version, &updatedAt,
); err != nil {
return nil, err
}
item.LifecycleState = DocumentState(state)
if !updatedAt.IsZero() {
item.UpdatedAt = updatedAt.Format(time.RFC3339)
}
out = append(out, item)
}
return out, rows.Err()
}
e.mu.Lock()
defer e.mu.Unlock()
out := make([]WorkflowDocumentSummary, 0)
for _, doc := range e.memDocs {
if doc.ProjectID == projectID && doc.TaskKey != "" {
out = append(out, WorkflowDocumentSummary{
DocID: doc.DocID,
ProjectID: doc.ProjectID,
TaskKey: doc.TaskKey,
Title: doc.Title,
LifecycleState: doc.CurrentState,
IsTemplateOnly: doc.IsTemplateOnly,
ContentSHA256: doc.ContentSHA256,
WorkDir: doc.WorkDir,
Version: doc.Version,
})
}
}
return out, nil
}
// ReadDocumentContent 读取受控文档 output.md 正文并校验 SHA-256。
func (e *GovernanceEngine) ReadDocumentContent(ctx context.Context, docID string, repoRoot string) (*DocumentContent, error) {
doc, err := e.GetDocument(ctx, docID)
if err != nil {
return nil, err
}
if doc.WorkDir == "" {
return nil, fmt.Errorf("document has no work_dir")
}
workDir := doc.WorkDir
if !filepath.IsAbs(workDir) && repoRoot != "" {
workDir = filepath.Join(repoRoot, workDir)
}
path := filepath.Join(workDir, "output.md")
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read document content: %w", err)
}
if len(data) == 0 {
return nil, fmt.Errorf("document content is empty")
}
sum := sha256.Sum256(data)
shaStr := hex.EncodeToString(sum[:])
if doc.ContentSHA256 != "" && !strings.EqualFold(doc.ContentSHA256, shaStr) {
return nil, fmt.Errorf("content sha256 mismatch: record=%s file=%s", doc.ContentSHA256, shaStr)
}
return &DocumentContent{
DocID: doc.DocID,
Version: doc.Version,
ContentSHA256: shaStr,
Markdown: string(data),
IsTemplateOnly: doc.IsTemplateOnly,
}, nil
}