Files
Agent-System/internal/server/project_doc_handlers.go
T
weichenandCursor 8892290bf7 fix(docgov): keep repair revision numbers and refuse drifted heads
migration_repair is not a human revision. HashUpdater now loads from DB
and retries still sync. Content reads return a structured drift conflict
instead of the destructive old head.

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

192 lines
6.0 KiB
Go

package server
import (
"context"
"errors"
"fmt"
"net/http"
"path/filepath"
"strconv"
"time"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
)
func (s *Server) repoRoot() string {
if s.cfg != nil && s.cfg.DataDir != "" {
// DataDir 形如 ./data/dev;工作区路径形如 data/dev/workspaces/...,相对仓库根目录
return filepath.Clean(filepath.Join(s.cfg.DataDir, "..", ".."))
}
return "."
}
// handleListProjectWorkflowDocuments 列出项目工作流受控文档。
func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
http.Error(w, "invalid project id", http.StatusBadRequest)
return
}
category := r.URL.Query().Get("category")
if category != "" && category != "workflow" {
http.Error(w, "unsupported category", http.StatusBadRequest)
return
}
user, ok := s.requireProjectAccess(w, r, projectID)
if !ok {
return
}
if s.svc == nil || s.svc.DocGov() == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
return
}
docs, err := s.svc.DocGov().ListWorkflowDocumentsByProject(r.Context(), projectID)
if err != nil {
http.Error(w, "list project documents failed: "+err.Error(), http.StatusInternalServerError)
return
}
docs = s.filterWorkflowDocsByStageAccess(r.Context(), projectID, user.ID, docs)
writeJSONResponse(w, http.StatusOK, docs)
}
// handleDocGovGetDocumentContent 读取受控文档 Markdown 正文。
func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.Request) {
docID := r.PathValue("doc_id")
if docID == "" {
http.Error(w, "doc_id required", http.StatusBadRequest)
return
}
if s.svc == nil || s.svc.DocGov() == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
return
}
docRec, err := s.svc.DocGov().GetDocument(r.Context(), docID)
if err != nil || docRec == nil {
http.Error(w, "document not found", http.StatusNotFound)
return
}
user, ok := s.requireProjectAccess(w, r, docRec.ProjectID)
if !ok {
return
}
if !s.allowWorkflowDocTaskAccess(r.Context(), docRec.ProjectID, user.ID, docRec.TaskKey) {
http.Error(w, `{"error":"WORKFLOW_ACCESS_DENIED","message":"无权访问该阶段文档"}`, http.StatusForbidden)
return
}
content, err := s.loadControlledDocumentContent(r.Context(), docID)
if err != nil {
writeControlledContentError(w, r, err)
return
}
if inm := r.Header.Get("If-None-Match"); inm != "" && inm == content.ContentSHA256 {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", content.ContentSHA256)
writeJSONResponse(w, http.StatusOK, content)
}
func (s *Server) loadControlledDocumentContent(ctx context.Context, docID string) (*docgov.DocumentContent, error) {
if rev := s.docRev(); rev != nil {
if md, sha, err := rev.HeadContent(ctx, docID); err == nil && md != "" {
class, ierr := rev.InspectProjection(ctx, docID, s.repoRoot())
if ierr != nil {
return nil, ierr
}
doc, _ := s.svc.DocGov().GetDocument(ctx, docID)
ver := 0
tmpl := false
recordSHA := sha
if doc != nil {
ver = doc.Version
tmpl = doc.IsTemplateOnly
if doc.ContentSHA256 != "" {
recordSHA = doc.ContentSHA256
}
}
return serveLedgerHeadOrConflict(docID, ver, tmpl, md, sha, recordSHA, class)
}
}
return s.svc.DocGov().ReadDocumentContent(ctx, docID, s.repoRoot())
}
// serveLedgerHeadOrConflict returns ledger head only when the projection is aligned.
// Legacy destructive drift or a real fork must not silently display the old stripped head.
func serveLedgerHeadOrConflict(docID string, version int, tmpl bool, headMD, headSHA, recordSHA string, class docrevision.ProjectionClass) (*docgov.DocumentContent, error) {
switch class.Class {
case "", docrevision.ProjectionAligned, docrevision.ProjectionNoLedger:
return &docgov.DocumentContent{
DocID: docID, Version: version, ContentSHA256: headSHA, Markdown: headMD, IsTemplateOnly: tmpl,
}, nil
case docrevision.ProjectionMissing:
ce := docgov.NewContentError(docgov.ErrCodeProjectionMissing, docgov.ErrContentProjectionMissing, true, true)
ce.RecordSHA = recordSHA
ce.HeadSHA = headSHA
ce.Class = class.Class
return nil, ce
case docrevision.ProjectionEmpty:
ce := docgov.NewContentError(docgov.ErrCodeProjectionEmpty, docgov.ErrContentProjectionEmpty, false, true)
ce.RecordSHA = recordSHA
ce.HeadSHA = headSHA
ce.Class = class.Class
return nil, ce
default:
ce := docgov.NewContentError(
docgov.ErrCodeProjectionDrift,
fmt.Errorf("document content projection drift %s; repair required before serving ledger head", class.Class),
false,
true,
)
ce.RecordSHA = recordSHA
ce.FileSHA = class.RawSHA
ce.HeadSHA = headSHA
ce.Class = class.Class
return nil, ce
}
}
func writeControlledContentError(w http.ResponseWriter, r *http.Request, err error) {
corr := r.Header.Get("X-Request-ID")
if corr == "" {
corr = strconv.FormatInt(time.Now().UnixNano(), 10)
}
var ce *docgov.ContentError
if errors.As(err, &ce) {
status := http.StatusConflict
if ce.Code == docgov.ErrCodeDocumentNotFound {
status = http.StatusNotFound
}
writeJSONResponse(w, status, map[string]any{
"error_code": ce.Code,
"message": ce.Error(),
"retryable": ce.Retryable,
"needs_admin": ce.NeedsAdmin,
"correlation_id": corr,
"record_sha256": ce.RecordSHA,
"file_sha256": ce.FileSHA,
"head_sha256": ce.HeadSHA,
"projection_class": ce.Class,
})
return
}
if errors.Is(err, docgov.ErrDocumentNotFound) {
writeJSONResponse(w, http.StatusNotFound, map[string]any{
"error_code": docgov.ErrCodeDocumentNotFound, "message": err.Error(),
"retryable": false, "needs_admin": false, "correlation_id": corr,
})
return
}
writeJSONResponse(w, http.StatusConflict, map[string]any{
"error_code": "DOCUMENT_CONTENT_READ_FAILED", "message": err.Error(),
"retryable": true, "needs_admin": false, "correlation_id": corr,
})
}