User authorized lifting CURRENT FAV whole-branch merge protection on 2026-09-23. Keep FAV_DEGRADED_READ/FAV_PUBLISHER_ENABLED default false. Drop duplicate goose 00034 (local_dev already has 00036). Preserve agentx workspace admin routes and pin-export wiring.
188 lines
6.8 KiB
Go
188 lines
6.8 KiB
Go
package docgov
|
|
|
|
import (
|
|
"chat-agent-system/internal/workspace"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"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"`
|
|
WorkflowKey string `json:"workflow_key,omitempty"`
|
|
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"`
|
|
DutyPolicy string `json:"duty_policy,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"`
|
|
Consistency string `json:"consistency,omitempty"`
|
|
RecordSHA256 string `json:"record_sha256,omitempty"`
|
|
FileSHA256 string `json:"file_sha256,omitempty"`
|
|
Source string `json:"source,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 := 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, ''),
|
|
COALESCE(duty_policy, '')
|
|
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, &item.DutyPolicy,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
item.LifecycleState = DocumentState(state)
|
|
if !updatedAt.IsZero() {
|
|
item.UpdatedAt = updatedAt.Format(time.RFC3339)
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return collapseWorkflowDocuments(out), nil
|
|
}
|
|
|
|
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,
|
|
DutyPolicy: doc.DutyPolicy,
|
|
})
|
|
}
|
|
}
|
|
return collapseWorkflowDocuments(out), nil
|
|
}
|
|
|
|
// ReadDocumentContent 读取受控文档交付正文 并返回 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, workspace.DeliverableKey)
|
|
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
|
|
}
|