From 8892290bf7287979045c694661de9f36381ca68c Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 02:21:50 +0000 Subject: [PATCH] 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 --- internal/docgov/engine.go | 30 +++++-- internal/docgov/project_docs.go | 10 +-- internal/docgov/types.go | 4 +- internal/docrevision/projection.go | 70 ++++++++++++++++ internal/docrevision/projection_test.go | 49 +++++++++++ internal/docrevision/reconcile.go | 23 ++++-- internal/docrevision/reconcile_pg_test.go | 81 +++++++++++++++++-- internal/docrevision/reconcile_test.go | 78 ++++++++++++++++++ internal/docrevision/service.go | 5 ++ .../server/project_doc_content_error_test.go | 32 ++++++++ internal/server/project_doc_handlers.go | 66 ++++++++++++--- 11 files changed, 413 insertions(+), 35 deletions(-) diff --git a/internal/docgov/engine.go b/internal/docgov/engine.go index 364618a5..28e017a4 100644 --- a/internal/docgov/engine.go +++ b/internal/docgov/engine.go @@ -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() diff --git a/internal/docgov/project_docs.go b/internal/docgov/project_docs.go index beccf406..bf38cba5 100644 --- a/internal/docgov/project_docs.go +++ b/internal/docgov/project_docs.go @@ -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 diff --git a/internal/docgov/types.go b/internal/docgov/types.go index afed9a91..b06f9791 100644 --- a/internal/docgov/types.go +++ b/internal/docgov/types.go @@ -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() diff --git a/internal/docrevision/projection.go b/internal/docrevision/projection.go index 25166552..08743baf 100644 --- a/internal/docrevision/projection.go +++ b/internal/docrevision/projection.go @@ -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 { diff --git a/internal/docrevision/projection_test.go b/internal/docrevision/projection_test.go index 8fa63441..450bd9e4 100644 --- a/internal/docrevision/projection_test.go +++ b/internal/docrevision/projection_test.go @@ -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) + } +} diff --git a/internal/docrevision/reconcile.go b/internal/docrevision/reconcile.go index c6c95112..379d93b1 100644 --- a/internal/docrevision/reconcile.go +++ b/internal/docrevision/reconcile.go @@ -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") diff --git a/internal/docrevision/reconcile_pg_test.go b/internal/docrevision/reconcile_pg_test.go index 7b5d5b9c..a45ea8f1 100644 --- a/internal/docrevision/reconcile_pg_test.go +++ b/internal/docrevision/reconcile_pg_test.go @@ -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 "" + } } diff --git a/internal/docrevision/reconcile_test.go b/internal/docrevision/reconcile_test.go index 844a8b88..98caf234 100644 --- a/internal/docrevision/reconcile_test.go +++ b/internal/docrevision/reconcile_test.go @@ -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) } diff --git a/internal/docrevision/service.go b/internal/docrevision/service.go index fb237f0a..f0d08ec8 100644 --- a/internal/docrevision/service.go +++ b/internal/docrevision/service.go @@ -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 diff --git a/internal/server/project_doc_content_error_test.go b/internal/server/project_doc_content_error_test.go index 244eec2b..3e4f2554 100644 --- a/internal/server/project_doc_content_error_test.go +++ b/internal/server/project_doc_content_error_test.go @@ -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) } } diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index 11566434..30792e32 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -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 }