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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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")
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
@@ -130,6 +173,7 @@ func writeControlledContentError(w http.ResponseWriter, r *http.Request, err err
|
||||
"record_sha256": ce.RecordSHA,
|
||||
"file_sha256": ce.FileSHA,
|
||||
"head_sha256": ce.HeadSHA,
|
||||
"projection_class": ce.Class,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user