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>
This commit is contained in:
weichen
2026-09-14 02:21:50 +00:00
co-authored by Cursor
parent e7b46e92d2
commit 8892290bf7
11 changed files with 413 additions and 35 deletions
+25 -5
View File
@@ -194,14 +194,15 @@ func (e *GovernanceEngine) CreateOrGetDocument(
return doc, nil
}
// MarkDocumentContentUpdated 当正文生成或更新时标记正文哈希及是否脱离模板状态
// MarkDocumentContentUpdated 当正文生成或更新时标记正文哈希及是否脱离模板状态。
// 未预加载时从 DB 补偿装入;已是目标哈希则幂等成功,保证 repair 重试也能同步记录。
func (e *GovernanceEngine) MarkDocumentContentUpdated(ctx context.Context, docID string, contentSHA256 string, isTemplateOnly bool) error {
e.mu.Lock()
defer e.mu.Unlock()
doc, exists := e.memDocs[docID]
if !exists {
return ErrDocumentNotFound
doc, err := e.ensureDocumentLocked(ctx, docID)
if err != nil {
return err
}
doc.ContentSHA256 = contentSHA256
@@ -209,16 +210,35 @@ func (e *GovernanceEngine) MarkDocumentContentUpdated(ctx context.Context, docID
doc.UpdatedAt = time.Now()
if e.pool != nil {
_, _ = e.pool.Exec(ctx, `
tag, execErr := e.pool.Exec(ctx, `
UPDATE document_records
SET content_sha256 = $1, is_template_only = $2, work_dir = $3, updated_at = $4
WHERE doc_id = $5
`, doc.ContentSHA256, doc.IsTemplateOnly, doc.WorkDir, doc.UpdatedAt, docID)
if execErr != nil {
return fmt.Errorf("docgov: mark content updated: %w", execErr)
}
if tag.RowsAffected() == 0 {
return ErrDocumentNotFound
}
}
return nil
}
func (e *GovernanceEngine) ensureDocumentLocked(ctx context.Context, docID string) (*DocumentRecord, error) {
if doc, exists := e.memDocs[docID]; exists {
return doc, nil
}
if e.pool != nil {
doc, err := e.loadDocumentFromDBLocked(ctx, docID)
if err == nil && doc != nil {
return doc, nil
}
}
return nil, ErrDocumentNotFound
}
// SetDocumentWorkDir 更新文档的工作区路径
func (e *GovernanceEngine) SetDocumentWorkDir(ctx context.Context, docID string, workDir string) error {
e.mu.Lock()
+5 -5
View File
@@ -133,12 +133,12 @@ func (e *GovernanceEngine) ReadDocumentContent(ctx context.Context, docID string
doc, err := e.GetDocument(ctx, docID)
if err != nil {
if errors.Is(err, ErrDocumentNotFound) {
return nil, newContentError(ErrCodeDocumentNotFound, err, false, false)
return nil, NewContentError(ErrCodeDocumentNotFound, err, false, false)
}
return nil, err
}
if doc.WorkDir == "" {
return nil, newContentError(ErrCodeProjectionMissing, ErrContentProjectionMissing, false, true)
return nil, NewContentError(ErrCodeProjectionMissing, ErrContentProjectionMissing, false, true)
}
workDir := doc.WorkDir
@@ -148,12 +148,12 @@ func (e *GovernanceEngine) ReadDocumentContent(ctx context.Context, docID string
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 := 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 := NewContentError(ErrCodeProjectionEmpty, ErrContentProjectionEmpty, false, true)
ce.RecordSHA = doc.ContentSHA256
return nil, ce
}
@@ -161,7 +161,7 @@ func (e *GovernanceEngine) ReadDocumentContent(ctx context.Context, docID string
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 := 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
+3 -1
View File
@@ -31,6 +31,7 @@ const (
ErrCodeProjectionMissing = "DOCUMENT_CONTENT_PROJECTION_MISSING"
ErrCodeProjectionEmpty = "DOCUMENT_CONTENT_PROJECTION_EMPTY"
ErrCodeIntegrityMismatch = "DOCUMENT_CONTENT_INTEGRITY_MISMATCH"
ErrCodeProjectionDrift = "DOCUMENT_CONTENT_PROJECTION_DRIFT"
)
// ContentError is a structured content-read failure. 404 is only for a missing record.
@@ -42,6 +43,7 @@ type ContentError struct {
RecordSHA string
FileSHA string
HeadSHA string
Class string
cause error
}
@@ -62,7 +64,7 @@ func (e *ContentError) Unwrap() error {
return e.cause
}
func newContentError(code string, cause error, retryable, needsAdmin bool) *ContentError {
func NewContentError(code string, cause error, retryable, needsAdmin bool) *ContentError {
msg := code
if cause != nil {
msg = cause.Error()
+70
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"chat-agent-system/internal/fsutil"
)
@@ -63,6 +64,11 @@ func ClassifyProjection(raw []byte, headMarkdown string) ProjectionClass {
}
func (s *Service) resolveWorkDir(workDir, repoRoot string) string {
if s != nil {
if mapped, err := MapStoredDataPath(workDir, s.StoredDataRoot, s.HostDataRoot); err == nil && mapped != "" {
workDir = mapped
}
}
if filepath.IsAbs(workDir) {
return workDir
}
@@ -76,6 +82,70 @@ func (s *Service) resolveWorkDir(workDir, repoRoot string) string {
return filepath.Join(base, workDir)
}
// MapStoredDataPath rewrites a stored work_dir under storedRoot onto hostRoot.
// Both roots must be set together; otherwise workDir is returned unchanged.
func MapStoredDataPath(workDir, storedRoot, hostRoot string) (string, error) {
workDir = strings.TrimSpace(workDir)
storedRoot = strings.TrimSpace(storedRoot)
hostRoot = strings.TrimSpace(hostRoot)
if workDir == "" {
return "", nil
}
if storedRoot == "" && hostRoot == "" {
return workDir, nil
}
if storedRoot == "" || hostRoot == "" {
return "", fmt.Errorf("docrevision: stored-data-root and host-data-root must be set together")
}
workSlash := filepath.ToSlash(filepath.Clean(workDir))
storedSlash := filepath.ToSlash(filepath.Clean(storedRoot))
host := filepath.Clean(hostRoot)
if workSlash == storedSlash {
return host, nil
}
prefix := storedSlash + "/"
if !strings.HasPrefix(workSlash, prefix) {
return workDir, nil
}
rel := strings.TrimPrefix(workSlash, prefix)
mapped := filepath.Join(host, filepath.FromSlash(rel))
mappedSlash := filepath.ToSlash(filepath.Clean(mapped))
hostSlash := filepath.ToSlash(host)
if mappedSlash != hostSlash && !strings.HasPrefix(mappedSlash, hostSlash+"/") {
return "", fmt.Errorf("docrevision: mapped path escapes host data root")
}
return mapped, nil
}
// InspectProjection classifies output.md against the current ledger head.
func (s *Service) InspectProjection(ctx context.Context, docID, repoRoot string) (ProjectionClass, error) {
if err := requireDocID(docID); err != nil {
return ProjectionClass{}, err
}
s.mu.Lock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
s.mu.Unlock()
return ProjectionClass{}, err
}
workDir, headMD, _, _, ok := s.lookupHeadLocked(docID)
s.mu.Unlock()
if !ok {
return ProjectionClass{Class: ProjectionNoLedger}, nil
}
path := s.resolveWorkDir(workDir, repoRoot)
if path == "" {
return ProjectionClass{Class: ProjectionMissing, HeadSHA: SHA256Hex(CanonicalizeMarkdown(headMD))}, nil
}
raw, err := os.ReadFile(filepath.Join(path, "output.md"))
if err != nil {
if os.IsNotExist(err) {
return ClassifyProjection(nil, headMD), nil
}
return ProjectionClass{}, err
}
return ClassifyProjection(raw, headMD), nil
}
func (s *Service) lookupHeadLocked(docID string) (workDir, md, sha string, snapID int64, ok bool) {
root := s.roots[docID]
if root == nil || root.HeadSnapshotID == nil {
+49
View File
@@ -92,3 +92,52 @@ func TestProjectOutputMarkdown_RefusesSilentOverwriteOfLegacyFile(t *testing.T)
t.Fatalf("file must stay untouched, got %q", got)
}
}
func TestMapStoredDataPath_RewritesContainerRoot(t *testing.T) {
mapped, err := docrevision.MapStoredDataPath(
"/app/data/workspaces/bizdev/intake_1",
"/app/data",
"/repo/data/dev",
)
if err != nil {
t.Fatal(err)
}
want := filepath.Join("/repo/data/dev", "workspaces", "bizdev", "intake_1")
if mapped != want {
t.Fatalf("mapped %q want %q", mapped, want)
}
unchanged, err := docrevision.MapStoredDataPath("/tmp/other", "/app/data", "/repo/data/dev")
if err != nil || unchanged != "/tmp/other" {
t.Fatalf("unrelated path %q err=%v", unchanged, err)
}
if _, err := docrevision.MapStoredDataPath("/app/data/x", "/app/data", ""); err == nil {
t.Fatal("half mapping must fail")
}
}
func TestInspectProjection_UsesHostDataRootMapping(t *testing.T) {
ctx := t.Context()
host := t.TempDir()
ws := filepath.Join(host, "workspaces", "intake")
if err := os.MkdirAll(ws, 0o755); err != nil {
t.Fatal(err)
}
raw := "条目 \n结尾\n"
if err := os.WriteFile(filepath.Join(ws, "output.md"), []byte(raw), 0o644); err != nil {
t.Fatal(err)
}
svc := docrevision.NewService(nil)
svc.StoredDataRoot = "/app/data"
svc.HostDataRoot = host
legacy := docrevision.LegacyDestructiveCanonicalizeMarkdown(raw)
if _, err := svc.OpenTemplateVersion(ctx, "doc_map", legacy, docrevision.ActorSnapshot{}, 1, "/app/data/workspaces/intake"); err != nil {
t.Fatal(err)
}
class, err := svc.InspectProjection(ctx, "doc_map", "")
if err != nil {
t.Fatal(err)
}
if class.Class != docrevision.ProjectionLegacyCanonicalizationDrift {
t.Fatalf("mapped inspect class=%s", class.Class)
}
}
+18 -5
View File
@@ -72,6 +72,11 @@ func (s *Service) ReconcileDocument(ctx context.Context, req ReconcileRequest) (
out.NewHeadSHA = headSHA
out.NewHeadID = oldHeadID
out.Detail = "file matches head"
if req.Apply && req.HashUpdater != nil {
if err := req.HashUpdater.MarkDocumentContentUpdated(ctx, req.DocID, headSHA, false); err != nil {
return nil, err
}
}
return out, nil
}
if class.Class != ProjectionLegacyCanonicalizationDrift {
@@ -142,6 +147,9 @@ func (s *Service) CommitMigrationRepair(ctx context.Context, req MigrationRepair
parentID := *root.HeadSnapshotID
parent := s.snapshots[parentID]
if parent != nil && parent.ContentSHA256 == sha {
if err := s.syncHashUpdaterLocked(ctx, req.HashUpdater, req.DocID, sha); err != nil {
return nil, err
}
return s.mustStateLocked(req.DocID), nil
}
actor := ActorSnapshot{DisplayName: "migration_repair", Role: "system"}
@@ -153,7 +161,7 @@ func (s *Service) CommitMigrationRepair(ctx context.Context, req MigrationRepair
snap := s.putSnapshotLocked(req.DocID, canon, sha, parentPtr, SourceMigrationRepair, nil, "migration_repair", actor, "", nil)
ver.HeadSnapshotID = snap.ID
root.HeadSnapshotID = &snap.ID
root.HeadRevisionNo++
// migration_repair is not a human revision. Head snapshot ID already changes ETag.
root.ContentSHA256 = sha
root.RowRevision++
eventNo := s.nextEventNoLocked(req.DocID)
@@ -162,14 +170,19 @@ func (s *Service) CommitMigrationRepair(ctx context.Context, req MigrationRepair
s.forgetDocLocked(req.DocID)
return nil, err
}
if req.HashUpdater != nil {
if err := req.HashUpdater.MarkDocumentContentUpdated(ctx, req.DocID, sha, false); err != nil {
return nil, err
}
if err := s.syncHashUpdaterLocked(ctx, req.HashUpdater, req.DocID, sha); err != nil {
return nil, err
}
return s.mustStateLocked(req.DocID), nil
}
func (s *Service) syncHashUpdaterLocked(ctx context.Context, updater ContentHashUpdater, docID, sha string) error {
if updater == nil {
return nil
}
return updater.MarkDocumentContentUpdated(ctx, docID, sha, false)
}
func backupReconcileArtifacts(backupDir, docID string, raw []byte, headID int64, headSHA, headMD string) (string, string, error) {
if backupDir == "" {
return "", "", fmt.Errorf("docrevision: backup dir required before apply")
+73 -8
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -16,10 +17,10 @@ import (
)
func TestReconcile_LegacyRepair_PG(t *testing.T) {
if testing.Short() {
t.Skip("skipping PostgreSQL reconcile integration in -short")
}
dsn := db.SkipIfNoTestDatabaseURL(t)
if sharedDSNEnv(dsn) != "" {
t.Fatal("TEST_DATABASE_URL points at a shared env port; refuse agent_system_dev/test/main")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
@@ -53,19 +54,23 @@ func TestReconcile_LegacyRepair_PG(t *testing.T) {
_, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE doc_id=$1`, docID)
})
eng := docgov.NewGovernanceEngine(pool)
if _, err := eng.CreateOrGetDocument(ctx, docID, 1, "intake", "t", "V0.1", workDir, 9, "a", 2, "r", 3, "p"); err != nil {
seed := docgov.NewGovernanceEngine(pool)
if _, err := seed.CreateOrGetDocument(ctx, docID, 1, "intake", "t", "V0.1", workDir, 9, "a", 2, "r", 3, "p"); err != nil {
t.Fatal(err)
}
_ = eng.MarkDocumentContentUpdated(ctx, docID, docrevision.SHA256Hex(legacy), false)
_ = seed.MarkDocumentContentUpdated(ctx, docID, docrevision.SHA256Hex(legacy), false)
svc := docrevision.NewService(pool)
svc.DataDir = dir
if _, err := svc.OpenTemplateVersion(ctx, docID, legacy, docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir); err != nil {
st, err := svc.OpenTemplateVersion(ctx, docID, legacy, docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
if err != nil {
t.Fatal(err)
}
// CLI-shaped HashUpdater: fresh engine, no GetDocument preload.
fresh := docgov.NewGovernanceEngine(pool)
res, err := svc.ReconcileDocument(ctx, docrevision.ReconcileRequest{
DocID: docID, Apply: true, BackupDir: filepath.Join(dir, "backup"), HashUpdater: eng,
DocID: docID, Apply: true, BackupDir: filepath.Join(dir, "backup"), HashUpdater: fresh,
})
if err != nil {
t.Fatal(err)
@@ -84,4 +89,64 @@ func TestReconcile_LegacyRepair_PG(t *testing.T) {
if sha != docrevision.SHA256Hex(docrevision.CanonicalizeMarkdown(raw)) || md != docrevision.CanonicalizeMarkdown(raw) {
t.Fatalf("head not repaired")
}
var humanCount, headRev int
if err := pool.QueryRow(ctx, `SELECT human_revision_count FROM document_versions WHERE doc_id=$1 AND status='open'`, docID).Scan(&humanCount); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT head_revision_no FROM document_records WHERE doc_id=$1`, docID).Scan(&headRev); err != nil {
t.Fatal(err)
}
if humanCount != st.ManualChangeCount || headRev != st.HeadRevisionNo {
t.Fatalf("repair must keep revision counters human=%d head_rev=%d (was human=%d head_rev=%d)", humanCount, headRev, st.ManualChangeCount, st.HeadRevisionNo)
}
var sourceKind, changeKind string
if err := pool.QueryRow(ctx, `SELECT source_kind FROM document_snapshots WHERE id=$1`, res.NewHeadID).Scan(&sourceKind); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT change_kind FROM document_change_events WHERE doc_id=$1 ORDER BY event_no DESC LIMIT 1`, docID).Scan(&changeKind); err != nil {
t.Fatal(err)
}
if sourceKind != docrevision.SourceMigrationRepair || changeKind != docrevision.ChangeMigrationRepair {
t.Fatalf("kinds source=%s change=%s", sourceKind, changeKind)
}
var recordSHA string
if err := pool.QueryRow(ctx, `SELECT content_sha256 FROM document_records WHERE doc_id=$1`, docID).Scan(&recordSHA); err != nil {
t.Fatal(err)
}
if recordSHA != sha {
t.Fatalf("fresh HashUpdater must persist record sha %s want %s", recordSHA, sha)
}
retry := docgov.NewGovernanceEngine(pool)
again, err := svc.CommitMigrationRepair(ctx, docrevision.MigrationRepairRequest{
DocID: docID, Markdown: raw, HashUpdater: retry,
})
if err != nil {
t.Fatal(err)
}
if again.HeadSHA256 != sha {
t.Fatalf("retry changed head sha %s", again.HeadSHA256)
}
if err := pool.QueryRow(ctx, `SELECT content_sha256 FROM document_records WHERE doc_id=$1`, docID).Scan(&recordSHA); err != nil {
t.Fatal(err)
}
if recordSHA != sha {
t.Fatalf("retry must keep record in sync")
}
}
func sharedDSNEnv(dsn string) string {
switch {
case strings.Contains(dsn, ":5433"):
return "dev"
case strings.Contains(dsn, ":5434"):
return "test"
case strings.Contains(dsn, ":5435"):
return "main"
default:
return ""
}
}
+78
View File
@@ -1,6 +1,7 @@
package docrevision_test
import (
"context"
"os"
"path/filepath"
"testing"
@@ -98,6 +99,9 @@ func TestReconcile_ApplyLegacyRepairKeepsFileAndMovesHead(t *testing.T) {
t.Fatal(err)
}
oldHead := st.HeadSnapshotID
oldRev := st.HeadRevisionNo
oldHuman := st.ManualChangeCount
oldETag := st.ETag
res, err := svc.ReconcileDocument(ctx, docrevision.ReconcileRequest{
DocID: "doc_proj143_intake", Apply: true, BackupDir: backup, HashUpdater: eng,
@@ -123,6 +127,15 @@ func TestReconcile_ApplyLegacyRepairKeepsFileAndMovesHead(t *testing.T) {
if st2.HeadSnapshotID == oldHead {
t.Fatal("must not keep snapshot-66-equivalent head; need new migration_repair snapshot")
}
if st2.HeadRevisionNo != oldRev {
t.Fatalf("migration_repair must keep head_revision_no %d, got %d", oldRev, st2.HeadRevisionNo)
}
if st2.ManualChangeCount != oldHuman {
t.Fatalf("migration_repair must keep human_revision_count %d, got %d", oldHuman, st2.ManualChangeCount)
}
if st2.ETag == oldETag {
t.Fatal("new head snapshot id must change ETag")
}
canon := docrevision.CanonicalizeMarkdown(raw)
if st2.HeadSHA256 != docrevision.SHA256Hex(canon) {
t.Fatalf("head sha %s want %s", st2.HeadSHA256, docrevision.SHA256Hex(canon))
@@ -210,3 +223,68 @@ func TestWritePaths_HumanSaveTrailingSpacesShareBytes(t *testing.T) {
t.Fatalf("head/file sha diverge")
}
}
type recordingUpdater struct {
calls int
sha string
err error
}
func (r *recordingUpdater) MarkDocumentContentUpdated(_ context.Context, _, contentSHA256 string, _ bool) error {
r.calls++
r.sha = contentSHA256
return r.err
}
func TestCommitMigrationRepair_RetryStillSyncsHashUpdater(t *testing.T) {
ctx := t.Context()
dir := t.TempDir()
workDir := filepath.Join(dir, "ws")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatal(err)
}
raw := project143Raw()
legacy := docrevision.LegacyDestructiveCanonicalizeMarkdown(raw)
svc := docrevision.NewService(nil)
svc.DataDir = dir
st, err := svc.OpenTemplateVersion(ctx, "doc_retry", legacy, docrevision.ActorSnapshot{}, 1, workDir)
if err != nil {
t.Fatal(err)
}
failing := &recordingUpdater{err: errUpdaterBoom}
_, err = svc.CommitMigrationRepair(ctx, docrevision.MigrationRepairRequest{
DocID: "doc_retry", Markdown: raw, HashUpdater: failing,
})
if err == nil {
t.Fatal("first updater failure must surface")
}
st2, err := svc.GetRevisionState(ctx, "doc_retry")
if err != nil {
t.Fatal(err)
}
if st2.HeadSnapshotID == st.HeadSnapshotID {
t.Fatal("ledger head already moved before updater failure")
}
ok := &recordingUpdater{}
st3, err := svc.CommitMigrationRepair(ctx, docrevision.MigrationRepairRequest{
DocID: "doc_retry", Markdown: raw, HashUpdater: ok,
})
if err != nil {
t.Fatal(err)
}
if ok.calls != 1 {
t.Fatalf("retry must still sync HashUpdater, calls=%d", ok.calls)
}
if ok.sha != st3.HeadSHA256 {
t.Fatalf("retry sha %s want %s", ok.sha, st3.HeadSHA256)
}
if st3.HeadRevisionNo != st.HeadRevisionNo || st3.ManualChangeCount != st.ManualChangeCount {
t.Fatalf("retry must not increment revision counters: %+v", st3)
}
}
var errUpdaterBoom = errString("updater boom")
type errString string
func (e errString) Error() string { return string(e) }
+5
View File
@@ -41,6 +41,11 @@ type Service struct {
// DataDir is the environment data root used to resolve relative WorkDir paths.
DataDir string
// StoredDataRoot / HostDataRoot rewrite container paths (e.g. /app/data)
// to a host data root for CLI live mode. Empty means no rewrite.
StoredDataRoot string
HostDataRoot string
// Feature flags
LedgerWrite bool
LedgerRead bool
@@ -2,11 +2,13 @@ package server
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
)
func TestWriteControlledContentError_MapsTypedCodes(t *testing.T) {
@@ -19,6 +21,7 @@ func TestWriteControlledContentError_MapsTypedCodes(t *testing.T) {
{&docgov.ContentError{Code: docgov.ErrCodeProjectionMissing, Message: "no file", NeedsAdmin: true, Retryable: true}, http.StatusConflict, docgov.ErrCodeProjectionMissing},
{&docgov.ContentError{Code: docgov.ErrCodeProjectionEmpty, Message: "empty", NeedsAdmin: true}, http.StatusConflict, docgov.ErrCodeProjectionEmpty},
{&docgov.ContentError{Code: docgov.ErrCodeIntegrityMismatch, Message: "mismatch", NeedsAdmin: true}, http.StatusConflict, docgov.ErrCodeIntegrityMismatch},
{&docgov.ContentError{Code: docgov.ErrCodeProjectionDrift, Message: "drift", NeedsAdmin: true, Class: "LEGACY_CANONICALIZATION_DRIFT"}, http.StatusConflict, docgov.ErrCodeProjectionDrift},
}
for _, tc := range cases {
rec := httptest.NewRecorder()
@@ -38,5 +41,34 @@ func TestWriteControlledContentError_MapsTypedCodes(t *testing.T) {
if body["correlation_id"] != "corr-1" {
t.Fatalf("correlation %v", body["correlation_id"])
}
if tc.err.(*docgov.ContentError).Class != "" && body["projection_class"] != tc.err.(*docgov.ContentError).Class {
t.Fatalf("projection_class %v", body["projection_class"])
}
}
}
func TestServeLedgerHeadOrConflict_RefusesDestructiveHead(t *testing.T) {
raw := "条目一 \n条目二 \n"
head := docrevision.LegacyDestructiveCanonicalizeMarkdown(raw)
class := docrevision.ClassifyProjection([]byte(raw), head)
if class.Class != docrevision.ProjectionLegacyCanonicalizationDrift {
t.Fatalf("class=%s", class.Class)
}
_, err := serveLedgerHeadOrConflict("doc_x", 1, false, head, docrevision.SHA256Hex(head), docrevision.SHA256Hex(head), class)
var ce *docgov.ContentError
if !errors.As(err, &ce) || ce.Code != docgov.ErrCodeProjectionDrift {
t.Fatalf("want projection drift, got %v", err)
}
if ce.Class != docrevision.ProjectionLegacyCanonicalizationDrift {
t.Fatalf("class %s", ce.Class)
}
aligned := docrevision.ClassifyProjection([]byte(raw), docrevision.CanonicalizeMarkdown(raw))
got, err := serveLedgerHeadOrConflict("doc_x", 1, false, docrevision.CanonicalizeMarkdown(raw), aligned.CanonSHA, aligned.CanonSHA, aligned)
if err != nil {
t.Fatal(err)
}
if got.Markdown != docrevision.CanonicalizeMarkdown(raw) {
t.Fatalf("aligned must serve preserved-hard-break head, got %q", got.Markdown)
}
}
+55 -11
View File
@@ -3,12 +3,14 @@ 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 {
@@ -95,21 +97,62 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R
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 &docgov.DocumentContent{
DocID: docID, Version: ver, ContentSHA256: sha, Markdown: md, IsTemplateOnly: tmpl,
}, nil
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 == "" {
@@ -122,14 +165,15 @@ func writeControlledContentError(w http.ResponseWriter, r *http.Request, err err
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,
"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
}