Files
Agent-System/internal/docgov/project_docs.go
T
weichenandCursor c754f7743d feat(docquery): serve ledger when projection is missing
Authorize first, then return immutable ledger markdown with typed consistency so a missing or drifted projection is no longer a 404/409. GET stays read-only and FAV degraded read stays off.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 14:07:05 +00:00

181 lines
6.4 KiB
Go

package docgov
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"chat-agent-system/internal/docduty"
)
// 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"`
LeaseHolderType LeaseHolderType `json:"lease_holder_type,omitempty"`
LeaseHolderID string `json:"lease_holder_id,omitempty"`
LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"`
LeaseHeartbeatAt *time.Time `json:"lease_heartbeat_at,omitempty"`
AuthorID int64 `json:"author_id"`
AuthorName string `json:"author_name"`
ReviewerID int64 `json:"reviewer_id"`
ReviewerName string `json:"reviewer_name"`
ApproverID int64 `json:"approver_id"`
ApproverName string `json:"approver_name"`
}
// 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"`
Consistency string `json:"consistency,omitempty"`
RecordSHA256 string `json:"record_sha256,omitempty"`
FileSHA256 string `json:"file_sha256,omitempty"`
}
// 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 {
if _, _, err := docduty.HealProjectDocuments(ctx, e.pool, projectID); err != nil {
return nil, fmt.Errorf("heal project document duties: %w", err)
}
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,
COALESCE(lease_holder_type, ''), COALESCE(lease_holder_id, ''),
lease_expires_at, lease_heartbeat_at,
COALESCE(author_id, 0), COALESCE(author_name, ''),
COALESCE(reviewer_id, 0), COALESCE(reviewer_name, ''),
COALESCE(approver_id, 0), COALESCE(approver_name, '')
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,
&item.LeaseHolderType, &item.LeaseHolderID, &item.LeaseExpiresAt, &item.LeaseHeartbeatAt,
&item.AuthorID, &item.AuthorName, &item.ReviewerID, &item.ReviewerName,
&item.ApproverID, &item.ApproverName,
); 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,
LeaseHolderType: doc.LeaseHolderType,
LeaseHolderID: doc.LeaseHolderID,
LeaseExpiresAt: doc.LeaseExpiresAt,
LeaseHeartbeatAt: doc.LeaseHeartbeatAt,
AuthorID: doc.AuthorID,
AuthorName: doc.AuthorName,
ReviewerID: doc.ReviewerID,
ReviewerName: doc.ReviewerName,
ApproverID: doc.ApproverID,
ApproverName: doc.ApproverName,
})
}
}
return out, nil
}
// ReadDocumentContent 读取受控文档 output.md 并返回 typed ContentError。
// 记录不存在才是 DOCUMENT_NOT_FOUND;缺文件/空文件/哈希冲突不得伪装成 404。
func (e *GovernanceEngine) ReadDocumentContent(ctx context.Context, docID string, repoRoot string) (*DocumentContent, error) {
doc, err := e.GetDocument(ctx, docID)
if err != nil {
if errors.Is(err, ErrDocumentNotFound) {
return nil, NewContentError(ErrCodeDocumentNotFound, err, false, false)
}
return nil, err
}
if doc.WorkDir == "" {
return nil, NewContentError(ErrCodeProjectionMissing, ErrContentProjectionMissing, false, true)
}
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 {
ce := NewContentError(ErrCodeProjectionMissing, fmt.Errorf("%w: %v", ErrContentProjectionMissing, err), true, true)
ce.RecordSHA = doc.ContentSHA256
return nil, ce
}
if len(data) == 0 {
ce := NewContentError(ErrCodeProjectionEmpty, ErrContentProjectionEmpty, false, true)
ce.RecordSHA = doc.ContentSHA256
return nil, ce
}
sum := sha256.Sum256(data)
shaStr := hex.EncodeToString(sum[:])
if doc.ContentSHA256 != "" && !strings.EqualFold(doc.ContentSHA256, shaStr) {
ce := NewContentError(ErrCodeIntegrityMismatch, fmt.Errorf("%w: record=%s file=%s", ErrContentIntegrityMismatch, doc.ContentSHA256, shaStr), false, true)
ce.RecordSHA = doc.ContentSHA256
ce.FileSHA = shaStr
return nil, ce
}
return &DocumentContent{
DocID: doc.DocID,
Version: doc.Version,
ContentSHA256: shaStr,
Markdown: string(data),
IsTemplateOnly: doc.IsTemplateOnly,
}, nil
}