fix(docrevision): harden empty docID, per-doc auth, and failed-save honesty

Stage generation without a real document must not panic or enter the ledger.
Unknown orphan IDs are denied, write paths check author/lease/lifecycle, PATCH
no longer returns a fake 200, and overlay matching survives client/server block id drift.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 09:02:54 +00:00
co-authored by Cursor
parent 95f0ead131
commit 7f8331f893
21 changed files with 723 additions and 236 deletions
@@ -27,7 +27,7 @@ severity: critical
- `blobs` / `snapshots` / `versions` / `events` / `candidates` / `reports` / `roots` / `idemp`
`GetRevisionState` 仅查内存 `s.roots[docID]`;无 hydrate、无 INSERT/SELECT。
`GetRevisionState` 仅查内存 `s.roots[docID]`;无 hydrate、无 INSERT/SELECT。
Goose `00028` 已建表且 CURRENT 记录 schema 28 healthy,但**运行态账本与 PG 脱节**。
**后果**:服务重启 / 多副本 / 冷启动后一律 404;backfill CLI 亦 `NewService(nil)`,apply 不落库。
+1 -1
View File
@@ -1,7 +1,7 @@
# 当前焦点
**更新**: 2026-09-13
**production_ready**: no(DOCREV 运行时账本未持久化导致现场 404;CHAT/INTENT 并行中)
**production_ready**: no(DOCREV 复核整改已在 `fix/document-revision-runtime` 落地,待占槽合 dev;CHAT/INTENT 并行中)
## 焦点
@@ -7,8 +7,8 @@ updated: 2026-09-13
# 文档版本账本运行时 — Ubuntu Server 无头浏览器真实 UI 验收计划
> **与工程门禁分离**:本文件仅描述**用户限定的最终实战验收**(浏览器页面操作)。
> **不得**用 API 直接灌库、fixture 伪造对话结果、或 stub 模型输出冒充验收证据。
> **与工程门禁分离**:本文件仅描述**用户限定的最终实战验收**(浏览器页面操作)。
> **不得**用 API 直接灌库、fixture 伪造对话结果、或 stub 模型输出冒充验收证据。
> 工程单测 / `go vet` / `npm test` / `npm run build` 属于「工程门禁」,见 short-term 证据区,**不能替代**本计划。
## 0. 环境前置(集成槽位占用后执行)
@@ -60,7 +60,9 @@ go test -short ./internal/docrevision/... ./internal/server/ ./internal/bizdev/
cd web && npm test && npm run build
```
PASS(2026-09-13):docrevision / server / bizdev / freedoc;web 191 node tests + 24 vitest;vite build OK。
PASS(2026-09-13 复核):`go vet ./...`;`go test -short ./...`(含 `cmd/bizdev-cli` 强制重跑);`web npm test` 193+24;`web npm run build`。
复核补强:空 docID 不再 panic;无文档记录不进 docrev;revision 全路由逐文档授权;orphan 读 404 / 写 403;candidate/report/decision 按 path doc_id hydrate;PATCH 投影/租约错误不再假 200;前端保存失败保留编辑;overlay 按正文/章节匹配;TX 失败 forget 内存态 + open version `FOR UPDATE`。
### 用户 UI 实战验收
+28
View File
@@ -172,3 +172,31 @@ func TestExecuteTask_LegacyDocIDDoesNotFailValidation(t *testing.T) {
t.Fatalf("expected doc_proj56_research record, got %#v", res.DocRecord)
}
}
func TestExecuteTask_EmptyDocIDSkipsDocRevision(t *testing.T) {
svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})
ctx := context.Background()
res, err := svc.ExecuteTask(ctx, TaskRunRequest{
TaskKey: "intake",
ProjectID: 0,
DocID: "",
ProjectName: "掌上便携超声",
CustomerName: "水木清研",
DeviceType: "二类掌上超声",
Backend: "stub",
Timeout: 10 * time.Second,
})
if err != nil {
t.Fatalf("empty docID ExecuteTask panicked or failed: %v", err)
}
if res == nil || !res.Success {
t.Fatalf("CLI-like stage path should succeed without doc ledger, got %#v", res)
}
if res.DocRecord != nil {
t.Fatalf("empty docID must not create a document record, got %#v", res.DocRecord)
}
if _, stErr := svc.DocRevision().GetRevisionState(ctx, ""); !strings.Contains(stErr.Error(), "invalid doc_id") {
t.Fatalf("empty GetRevisionState should be invalid, got %v", stErr)
}
}
+16 -14
View File
@@ -481,7 +481,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
var leaseAcquired bool
var appliedProposalID string
if s.docGov != nil {
if s.docGov != nil && strings.TrimSpace(docID) != "" {
docRec, _ = s.docGov.CreateOrGetDocument(
ctx, docID, req.ProjectID, req.TaskKey, artifactTitle,
"V0.5_feasibility", req.WorkDir,
@@ -731,7 +731,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
1.0, true)
}
if res.Success && s.docGov != nil {
if res.Success && s.docGov != nil && strings.TrimSpace(docID) != "" && docRec != nil {
_ = s.docGov.SetDocumentWorkDir(ctx, docID, res.WorkDir)
sha := sha256.Sum256([]byte(res.MarkdownBody))
shaStr := hex.EncodeToString(sha[:])
@@ -742,24 +742,26 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
docRec = latest
}
// DOCREV: bootstrap ledger or open agent candidate against current head.
if s.docRev != nil {
// DOCREV: only after a real document record exists; empty/orphan docID must not enter the ledger.
if s.docRev != nil && docRec != nil && strings.TrimSpace(docRec.DocID) != "" {
if _, stErr := s.docRev.GetRevisionState(ctx, docID); stErr != nil {
actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed}
_, _ = s.docRev.OpenTemplateVersion(ctx, docID, res.MarkdownBody, actor, req.ProjectID, res.WorkDir)
} else {
st, _ := s.docRev.GetRevisionState(ctx, docID)
cand, rep, _ := s.docRev.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey),
ProposalID: appliedProposalID, InputSnapshotID: st.HeadSnapshotID,
OutputMarkdown: res.MarkdownBody, ReportJSON: "", // deterministic report from diff+lineage
ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed},
})
if cand != nil && cand.Status != docrevision.CandidateStale && rep != nil && !docrevision.NeedsConfirmation(rep.RiskLevel) {
_, _ = s.docRev.CommitAgentVersion(ctx, cand.ID, docrevision.CommitAgentDecision{
Decision: "commit", Confirmed: true, ActorUserID: req.UserID,
ExpectedInputSHA: st.HeadSHA256,
if st != nil {
cand, rep, _ := s.docRev.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey),
ProposalID: appliedProposalID, InputSnapshotID: st.HeadSnapshotID,
OutputMarkdown: res.MarkdownBody, ReportJSON: "", // deterministic report from diff+lineage
ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed},
})
if cand != nil && cand.Status != docrevision.CandidateStale && rep != nil && !docrevision.NeedsConfirmation(rep.RiskLevel) {
_, _ = s.docRev.CommitAgentVersion(ctx, docID, cand.ID, docrevision.CommitAgentDecision{
Decision: "commit", Confirmed: true, ActorUserID: req.UserID,
ExpectedInputSHA: st.HeadSHA256,
})
}
}
}
}
+29 -5
View File
@@ -23,11 +23,11 @@ type LeaseAcquireRequest struct {
// ContentSaveRequest 正文/块内容保存请求
type ContentSaveRequest struct {
DocID string
ActorID int64
ActorRole UserRole
ActorName string
MarkdownBody string
DocID string
ActorID int64
ActorRole UserRole
ActorName string
MarkdownBody string
ExpectedSHA256 string // 乐观并发:非空时必须匹配当前 ContentSHA256
}
@@ -287,6 +287,30 @@ func (e *GovernanceEngine) MarkProposalApplied(ctx context.Context, proposalID s
return nil
}
// AssertHumanCanEdit acquires/renews the human lease and checks author duty + drafting lifecycle
// without writing document body. Used by revision PATCH before ledger mutation.
func (e *GovernanceEngine) AssertHumanCanEdit(ctx context.Context, docID string, actorID int64, actorRole UserRole) error {
holderID := fmt.Sprintf("%d", actorID)
if actorID <= 0 {
holderID = "human_anon"
}
if _, err := e.AcquireLease(ctx, LeaseAcquireRequest{
DocID: docID,
HolderType: LeaseHolderHuman,
HolderID: holderID,
TTL: DefaultHumanLeaseTTL,
}); err != nil {
return err
}
e.mu.Lock()
defer e.mu.Unlock()
doc, err := e.getDocLocked(ctx, docID)
if err != nil {
return err
}
return e.sm.CanEditDocumentContent(doc, actorID, actorRole)
}
// SaveDocumentContent 人工正文保存:校验可编辑 + 获取/续 human 租约 + 乐观哈希 + 落盘
func (e *GovernanceEngine) SaveDocumentContent(ctx context.Context, req ContentSaveRequest) (*ContentSaveResult, error) {
holderID := fmt.Sprintf("%d", req.ActorID)
+2 -2
View File
@@ -109,13 +109,13 @@ func TestContractTimeline_VersionRevisionSemantics(t *testing.T) {
if cand.Status != docrevision.CandidateNeedsConfirmation {
t.Fatalf("high risk should need confirmation, got %s risk=%s", cand.Status, rep.RiskLevel)
}
_, err = svc.CommitAgentVersion(ctx, cand.ID, docrevision.CommitAgentDecision{
_, err = svc.CommitAgentVersion(ctx, "doc_demo", cand.ID, docrevision.CommitAgentDecision{
Decision: "commit", Confirmed: false, ActorUserID: 10,
})
if err != docrevision.ErrNeedsConfirmation {
t.Fatalf("expected needs confirmation, got %v", err)
}
st3, err := svc.CommitAgentVersion(ctx, cand.ID, docrevision.CommitAgentDecision{
st3, err := svc.CommitAgentVersion(ctx, "doc_demo", cand.ID, docrevision.CommitAgentDecision{
Decision: "commit", Confirmed: true, ActorUserID: 10,
ExpectedInputSHA: r2.State.HeadSHA256,
})
@@ -0,0 +1,29 @@
package docrevision_test
import (
"context"
"errors"
"testing"
"chat-agent-system/internal/docrevision"
)
func TestGetRevisionState_EmptyDocID(t *testing.T) {
svc := docrevision.NewService(nil)
_, err := svc.GetRevisionState(context.Background(), "")
if !errors.Is(err, docrevision.ErrInvalidDocID) {
t.Fatalf("empty docID want ErrInvalidDocID, got %v", err)
}
_, err = svc.GetRevisionState(context.Background(), " ")
if !errors.Is(err, docrevision.ErrInvalidDocID) {
t.Fatalf("blank docID want ErrInvalidDocID, got %v", err)
}
}
func TestCommitAgentVersion_EmptyDocID(t *testing.T) {
svc := docrevision.NewService(nil)
_, err := svc.CommitAgentVersion(context.Background(), "", 1, docrevision.CommitAgentDecision{Decision: "commit"})
if !errors.Is(err, docrevision.ErrInvalidDocID) {
t.Fatalf("want ErrInvalidDocID, got %v", err)
}
}
+22
View File
@@ -12,6 +12,9 @@ func (s *Service) openTemplatePGLocked(ctx context.Context, docID, canon, sha st
blob := &ContentBlob{ContentSHA256: sha, ContentMarkdown: canon, ByteSize: int64(len(canon)), CreatedAt: time.Now().UTC()}
blocks := BuildBlockMap(canon, nil)
err := s.withTx(ctx, func(tx pgx.Tx) error {
if err := s.lockOpenVersionTX(ctx, tx, docID); err != nil {
return err
}
if err := s.ensureBlobTX(ctx, tx, blob); err != nil {
return err
}
@@ -61,6 +64,7 @@ func (s *Service) openTemplatePGLocked(ctx context.Context, docID, canon, sha st
return s.projectRootPointersTX(ctx, tx, root)
})
if err != nil {
s.forgetDocLocked(docID)
return nil, err
}
return s.mustStateLocked(docID), nil
@@ -72,6 +76,11 @@ func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootP
}
blob := s.blobs[snap.ContentSHA256]
return s.withTx(ctx, func(tx pgx.Tx) error {
if !isBaseline {
if err := s.lockOpenVersionTX(ctx, tx, root.DocID); err != nil {
return err
}
}
if err := s.ensureBlobTX(ctx, tx, blob); err != nil {
return err
}
@@ -136,6 +145,9 @@ func (s *Service) persistAgentCandidateLocked(ctx context.Context, outSnap *Snap
}
blob := s.blobs[outSnap.ContentSHA256]
return s.withTx(ctx, func(tx pgx.Tx) error {
if err := s.lockOpenVersionTX(ctx, tx, c.DocID); err != nil {
return err
}
if err := s.ensureBlobTX(ctx, tx, blob); err != nil {
return err
}
@@ -183,6 +195,9 @@ func (s *Service) persistAgentCommitLocked(ctx context.Context, root *DocRootPoi
return nil
}
return s.withTx(ctx, func(tx pgx.Tx) error {
if err := s.lockOpenVersionTX(ctx, tx, root.DocID); err != nil {
return err
}
if err := s.updateVersionTX(ctx, tx, oldVer); err != nil {
return err
}
@@ -222,3 +237,10 @@ func (s *Service) persistAgentCommitLocked(ctx context.Context, root *DocRootPoi
return s.projectRootPointersTX(ctx, tx, root)
})
}
func (s *Service) lockOpenVersionTX(ctx context.Context, tx pgx.Tx, docID string) error {
if _, err := tx.Exec(ctx, `SELECT id FROM document_versions WHERE doc_id=$1 AND status='open' FOR UPDATE`, docID); err != nil {
return fmt.Errorf("docrevision: lock open version: %w", err)
}
return nil
}
+24
View File
@@ -59,4 +59,28 @@ func TestPGPersistSurvivesNewServiceInstance(t *testing.T) {
if err != nil || ov == nil {
t.Fatalf("overlay after restart: %v", err)
}
st2, err := svc2.GetRevisionState(ctx, docID)
if err != nil {
t.Fatal(err)
}
cand, _, err := svc2.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
DocID: docID, RunID: "pg-cand-1", InputSnapshotID: st2.HeadSnapshotID,
OutputMarkdown: "# persist\n\nhello edited by agent\n",
ActorKind: docrevision.ActorAgent,
})
if err != nil || cand == nil {
t.Fatalf("create candidate: %v", err)
}
svc3 := docrevision.NewService(pool)
gotCand, err := svc3.GetCandidate(ctx, docID, cand.ID)
if err != nil || gotCand == nil || gotCand.DocID != docID {
t.Fatalf("hydrate candidate after restart: cand=%v err=%v", gotCand, err)
}
if _, err := svc3.CommitAgentVersion(ctx, docID, cand.ID, docrevision.CommitAgentDecision{
Decision: "reject", ActorUserID: 1,
}); err != nil {
t.Fatalf("decision after restart: %v", err)
}
}
+132 -28
View File
@@ -53,15 +53,15 @@ type Service struct {
func NewService(pool *pgxpool.Pool) *Service {
return &Service{
pool: pool,
blobs: map[string]*ContentBlob{},
snapshots: map[int64]*Snapshot{},
versions: map[int64]*Version{},
events: map[int64]*ChangeEvent{},
candidates: map[int64]*AgentCandidate{},
reports: map[int64]*ChangeReport{},
roots: map[string]*DocRootPointers{},
idemp: map[string]int64{},
pool: pool,
blobs: map[string]*ContentBlob{},
snapshots: map[int64]*Snapshot{},
versions: map[int64]*Version{},
events: map[int64]*ChangeEvent{},
candidates: map[int64]*AgentCandidate{},
reports: map[int64]*ChangeReport{},
roots: map[string]*DocRootPointers{},
idemp: map[string]int64{},
LedgerWrite: true,
LedgerRead: true,
AgentCommit: true,
@@ -87,18 +87,31 @@ func (s *Service) EnsureRoot(docID, kind string, projectID int64, lifecycle stri
return r
}
func requireDocID(docID string) error {
if strings.TrimSpace(docID) == "" {
return ErrInvalidDocID
}
return nil
}
func (s *Service) GetRevisionState(ctx context.Context, docID string) (*RevisionState, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
return nil, err
}
root, ok := s.roots[docID]
if !ok || root.LedgerVersionID == nil {
if !ok || root == nil || root.LedgerVersionID == nil || root.HeadSnapshotID == nil {
return nil, ErrNotFound
}
ver := s.versions[*root.LedgerVersionID]
head := s.snapshots[*root.HeadSnapshotID]
if ver == nil || head == nil {
return nil, ErrNotFound
}
caps := []string{"read"}
if root.LifecycleState == "drafting" {
caps = append(caps, "edit")
@@ -117,11 +130,15 @@ func (s *Service) GetRevisionState(ctx context.Context, docID string) (*Revision
ManualChangeCount: ver.HumanRevisionCount,
Capabilities: caps,
ETag: fmt.Sprintf("%d:%d", ver.HeadSnapshotID, root.HeadRevisionNo),
HeadBlockMap: append([]BlockNode(nil), head.BlockMap...),
}, nil
}
// EnsureBootstrap creates a ledger from fact-source markdown when none exists (idempotent).
func (s *Service) EnsureBootstrap(ctx context.Context, req BootstrapRequest) (*RevisionState, error) {
if err := requireDocID(req.DocID); err != nil {
return nil, err
}
if st, err := s.GetRevisionState(ctx, req.DocID); err == nil {
return st, nil
} else if err != nil && !errors.Is(err, ErrNotFound) {
@@ -153,6 +170,9 @@ func (s *Service) EnsureBootstrap(ctx context.Context, req BootstrapRequest) (*R
}
func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequest) (*HumanRevisionResult, error) {
if err := requireDocID(req.DocID); err != nil {
return nil, err
}
if req.IdempotencyKey == "" {
return nil, fmt.Errorf("docrevision: idempotency key required")
}
@@ -207,6 +227,7 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ
root.RowRevision++
ev := s.putEventLocked(req.DocID, ver.ID, 1, nil, snap.ID, ChangeImport, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, nil, req.EditSessionID, req.RequestID, req.IdempotencyKey)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, true); err != nil {
s.forgetDocLocked(req.DocID)
return nil, err
}
st := s.mustStateLocked(req.DocID)
@@ -257,6 +278,7 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ
eventNo := s.nextEventNoLocked(req.DocID)
ev := s.putEventLocked(req.DocID, ver.ID, eventNo, &parentID, snap.ID, ChangeHumanRevision, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, diff.Hunks, req.EditSessionID, req.RequestID, req.IdempotencyKey)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false); err != nil {
s.forgetDocLocked(req.DocID)
return nil, err
}
st := s.mustStateLocked(req.DocID)
@@ -268,6 +290,9 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ
}
func (s *Service) OpenTemplateVersion(ctx context.Context, docID string, markdown string, actor ActorSnapshot, projectID int64, workDir string) (*RevisionState, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
canon := CanonicalizeMarkdown(markdown)
sha := SHA256Hex(canon)
s.mu.Lock()
@@ -294,6 +319,9 @@ func (s *Service) OpenTemplateVersion(ctx context.Context, docID string, markdow
}
func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRequest) (*AgentCandidate, *ChangeReport, error) {
if err := requireDocID(req.DocID); err != nil {
return nil, nil, err
}
if !s.AgentCommit {
return nil, nil, fmt.Errorf("docrevision: agent commit disabled")
}
@@ -361,6 +389,7 @@ func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRe
}
s.candidates[c.ID] = c
if err := s.persistAgentCandidateLocked(ctx, outSnap, rep, c); err != nil {
s.forgetDocLocked(req.DocID)
return nil, nil, err
}
if s.Notify && s.OnOutbox != nil && status == CandidateNeedsConfirmation {
@@ -374,20 +403,17 @@ func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRe
return c, rep, nil
}
func (s *Service) CommitAgentVersion(ctx context.Context, candidateID int64, dec CommitAgentDecision) (*RevisionState, error) {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.candidates[candidateID]
if !ok {
// try hydrate by scanning — candidate may only be in PG; load via unknown doc is hard.
// Caller should hydrate doc first; fallback: search all not possible. Return not found.
return nil, ErrNotFound
}
if err := s.hydrateDocLocked(ctx, c.DocID); err != nil {
func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidateID int64, dec CommitAgentDecision) (*RevisionState, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
c, ok = s.candidates[candidateID]
if !ok {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
return nil, err
}
c, ok := s.candidates[candidateID]
if !ok || c.DocID != docID {
return nil, ErrNotFound
}
if c.Status == CandidateCommitted {
@@ -410,9 +436,15 @@ func (s *Service) CommitAgentVersion(ctx context.Context, candidateID int64, dec
if root.LifecycleState != "" && root.LifecycleState != "drafting" {
return nil, ErrLifecycleLocked
}
if c.ChangeReportID == nil || c.OutputSnapshotID == nil {
return nil, ErrNotFound
}
rep := s.reports[*c.ChangeReportID]
outSnap := s.snapshots[*c.OutputSnapshotID]
inSnap := s.snapshots[c.InputSnapshotID]
if rep == nil || outSnap == nil || inSnap == nil {
return nil, ErrNotFound
}
if dec.ExpectedInputSHA != "" && dec.ExpectedInputSHA != inSnap.ContentSHA256 {
return nil, ErrCandidateStale
}
@@ -424,7 +456,10 @@ func (s *Service) CommitAgentVersion(ctx context.Context, candidateID int64, dec
c.Status = CandidateRejected
c.DecidedAt = &now
if s.usingPG() {
_ = s.withTx(ctx, func(tx pgx.Tx) error { return s.updateCandidateTX(ctx, tx, c) })
if err := s.withTx(ctx, func(tx pgx.Tx) error { return s.updateCandidateTX(ctx, tx, c) }); err != nil {
s.forgetDocLocked(docID)
return nil, err
}
}
return s.mustStateLocked(c.DocID), nil
}
@@ -439,12 +474,15 @@ func (s *Service) CommitAgentVersion(ctx context.Context, candidateID int64, dec
c.Status = CandidateCommitted
c.DecidedAt = &now
if s.usingPG() {
_ = s.withTx(ctx, func(tx pgx.Tx) error {
if err := s.withTx(ctx, func(tx pgx.Tx) error {
if _, err := s.insertEventTX(ctx, tx, ev); err != nil {
return err
}
return s.updateCandidateTX(ctx, tx, c)
})
}); err != nil {
s.forgetDocLocked(docID)
return nil, err
}
}
return s.mustStateLocked(c.DocID), nil
}
@@ -476,6 +514,7 @@ func (s *Service) CommitAgentVersion(ctx context.Context, candidateID int64, dec
c.DecidedAt = &now
if err := s.persistAgentCommitLocked(ctx, root, oldVer, newVer, outSnap, ev, c); err != nil {
s.forgetDocLocked(docID)
return nil, err
}
@@ -506,6 +545,9 @@ func (d CommitAgentDecision) ActorUserIDPtr() *int64 {
}
func (s *Service) ListVersions(ctx context.Context, docID string) ([]Version, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
@@ -528,6 +570,9 @@ func (s *Service) ListVersions(ctx context.Context, docID string) ([]Version, er
}
func (s *Service) GetVersionContent(ctx context.Context, docID string, versionNo int, view string) (string, string, error) {
if err := requireDocID(docID); err != nil {
return "", "", err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
@@ -548,6 +593,9 @@ func (s *Service) GetVersionContent(ctx context.Context, docID string, versionNo
sid = ver.BaselineSnapshotID
}
snap := s.snapshots[sid]
if snap == nil || s.blobs[snap.ContentSHA256] == nil {
return "", "", ErrNotFound
}
return s.blobs[snap.ContentSHA256].ContentMarkdown, snap.ContentSHA256, nil
}
@@ -567,6 +615,9 @@ func (s *Service) DiffVersions(ctx context.Context, docID string, fromNo, toNo i
}
func (s *Service) ManualOverlay(ctx context.Context, docID string) (*DiffResult, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
@@ -577,9 +628,14 @@ func (s *Service) ManualOverlay(ctx context.Context, docID string) (*DiffResult,
}
func (s *Service) GetCandidate(ctx context.Context, docID string, candidateID int64) (*AgentCandidate, error) {
_ = ctx
if err := requireDocID(docID); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
return nil, err
}
c, ok := s.candidates[candidateID]
if !ok || c.DocID != docID {
return nil, ErrNotFound
@@ -589,9 +645,14 @@ func (s *Service) GetCandidate(ctx context.Context, docID string, candidateID in
}
func (s *Service) GetChangeReport(ctx context.Context, docID string, reportID int64) (*ChangeReport, error) {
_ = ctx
if err := requireDocID(docID); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
return nil, err
}
r, ok := s.reports[reportID]
if !ok || r.DocID != docID {
return nil, ErrNotFound
@@ -606,6 +667,9 @@ func (s *Service) ImportFreeformLegacy(ctx context.Context, docID string, ownerI
Markdown string
SHA string
}, currentMarkdown string) (*RevisionState, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
@@ -777,7 +841,7 @@ func (s *Service) mustStateLocked(docID string) *RevisionState {
root := s.roots[docID]
ver := s.versions[*root.LedgerVersionID]
head := s.snapshots[*root.HeadSnapshotID]
return &RevisionState{
st := &RevisionState{
DocID: docID, CurrentVersionNo: ver.VersionNo, VersionStatus: ver.Status,
BaselineSnapshotID: ver.BaselineSnapshotID, HeadSnapshotID: ver.HeadSnapshotID,
HeadRevisionNo: root.HeadRevisionNo, HeadSHA256: head.ContentSHA256,
@@ -785,6 +849,46 @@ func (s *Service) mustStateLocked(docID string) *RevisionState {
Capabilities: []string{"read", "edit", "diff", "overlay", "history"},
ETag: fmt.Sprintf("%d:%d", ver.HeadSnapshotID, root.HeadRevisionNo),
}
if head != nil {
st.HeadBlockMap = append([]BlockNode(nil), head.BlockMap...)
}
return st
}
// forgetDocLocked drops in-memory ledger rows for docID so a failed TX cannot linger as success.
func (s *Service) forgetDocLocked(docID string) {
if docID == "" {
return
}
delete(s.roots, docID)
for id, v := range s.versions {
if v != nil && v.DocID == docID {
delete(s.versions, id)
}
}
for id, sn := range s.snapshots {
if sn != nil && sn.DocID == docID {
delete(s.snapshots, id)
}
}
for id, ev := range s.events {
if ev != nil && ev.DocID == docID {
if ev.IdempotencyKey != "" {
delete(s.idemp, ev.IdempotencyKey)
}
delete(s.events, id)
}
}
for id, c := range s.candidates {
if c != nil && c.DocID == docID {
delete(s.candidates, id)
}
}
for id, r := range s.reports {
if r != nil && r.DocID == docID {
delete(s.reports, id)
}
}
}
func (s *Service) manualOverlayLocked(docID string) DiffResult {
+103 -100
View File
@@ -49,24 +49,26 @@ const (
RiskHigh = "high"
RiskCritical = "critical"
AnalysisComplete = "complete"
AnalysisFallback = "fallback"
AnalysisUnassessed = "unassessed"
AnalysisCorrected = "corrected"
AnalysisComplete = "complete"
AnalysisFallback = "fallback"
AnalysisUnassessed = "unassessed"
AnalysisCorrected = "corrected"
ErrCodeHeadConflict = "DOCUMENT_HEAD_CONFLICT"
ErrCodeEditForbidden = "DOCUMENT_EDIT_FORBIDDEN"
ErrCodeBusy = "DOCUMENT_BUSY"
ErrCodeCandidateStale = "AGENT_CANDIDATE_STALE"
ErrCodeReportInvalid = "CHANGE_REPORT_INVALID"
ErrCodeLifecycleLocked = "DOCUMENT_LIFECYCLE_LOCKED"
ErrCodeTooLarge = "DOCUMENT_TOO_LARGE"
ErrCodeNeedsConfirmation = "AGENT_NEEDS_CONFIRMATION"
ErrCodeNotFound = "DOCUMENT_REVISION_NOT_FOUND"
ErrCodeHeadConflict = "DOCUMENT_HEAD_CONFLICT"
ErrCodeEditForbidden = "DOCUMENT_EDIT_FORBIDDEN"
ErrCodeBusy = "DOCUMENT_BUSY"
ErrCodeCandidateStale = "AGENT_CANDIDATE_STALE"
ErrCodeReportInvalid = "CHANGE_REPORT_INVALID"
ErrCodeLifecycleLocked = "DOCUMENT_LIFECYCLE_LOCKED"
ErrCodeTooLarge = "DOCUMENT_TOO_LARGE"
ErrCodeNeedsConfirmation = "AGENT_NEEDS_CONFIRMATION"
ErrCodeNotFound = "DOCUMENT_REVISION_NOT_FOUND"
ErrCodeInvalidDocID = "DOCUMENT_REVISION_INVALID_DOC_ID"
)
var (
ErrNotFound = errors.New("docrevision: not found")
ErrInvalidDocID = errors.New("docrevision: invalid doc_id")
ErrHeadConflict = errors.New("docrevision: head conflict")
ErrLifecycleLocked = errors.New("docrevision: lifecycle locked")
ErrCandidateStale = errors.New("docrevision: candidate stale")
@@ -125,54 +127,54 @@ type Snapshot struct {
}
type Version struct {
ID int64 `json:"id"`
DocID string `json:"doc_id"`
VersionNo int `json:"version_no"`
Status string `json:"status"`
OriginKind string `json:"origin_kind"`
BaselineSnapshotID int64 `json:"baseline_snapshot_id"`
HeadSnapshotID int64 `json:"head_snapshot_id"`
HumanRevisionCount int `json:"human_revision_count"`
SourceRunID string `json:"source_run_id,omitempty"`
ChangeReportID *int64 `json:"change_report_id,omitempty"`
OpenedAt time.Time `json:"opened_at"`
SealedAt *time.Time `json:"sealed_at,omitempty"`
ID int64 `json:"id"`
DocID string `json:"doc_id"`
VersionNo int `json:"version_no"`
Status string `json:"status"`
OriginKind string `json:"origin_kind"`
BaselineSnapshotID int64 `json:"baseline_snapshot_id"`
HeadSnapshotID int64 `json:"head_snapshot_id"`
HumanRevisionCount int `json:"human_revision_count"`
SourceRunID string `json:"source_run_id,omitempty"`
ChangeReportID *int64 `json:"change_report_id,omitempty"`
OpenedAt time.Time `json:"opened_at"`
SealedAt *time.Time `json:"sealed_at,omitempty"`
}
type ChangeEvent struct {
ID int64 `json:"id"`
DocID string `json:"doc_id"`
VersionID int64 `json:"version_id"`
EventNo int `json:"event_no"`
FromSnapshotID *int64 `json:"from_snapshot_id,omitempty"`
ToSnapshotID int64 `json:"to_snapshot_id"`
ChangeKind string `json:"change_kind"`
ActorKind string `json:"actor_kind"`
ActorUserID *int64 `json:"actor_user_id,omitempty"`
ActorRef string `json:"actor_ref,omitempty"`
ActorSnapshot ActorSnapshot `json:"actor_snapshot"`
ChangeSet []DiffHunk `json:"change_set,omitempty"`
EditSessionID string `json:"edit_session_id,omitempty"`
RequestID string `json:"request_id,omitempty"`
IdempotencyKey string `json:"idempotency_key"`
CreatedAt time.Time `json:"created_at"`
ID int64 `json:"id"`
DocID string `json:"doc_id"`
VersionID int64 `json:"version_id"`
EventNo int `json:"event_no"`
FromSnapshotID *int64 `json:"from_snapshot_id,omitempty"`
ToSnapshotID int64 `json:"to_snapshot_id"`
ChangeKind string `json:"change_kind"`
ActorKind string `json:"actor_kind"`
ActorUserID *int64 `json:"actor_user_id,omitempty"`
ActorRef string `json:"actor_ref,omitempty"`
ActorSnapshot ActorSnapshot `json:"actor_snapshot"`
ChangeSet []DiffHunk `json:"change_set,omitempty"`
EditSessionID string `json:"edit_session_id,omitempty"`
RequestID string `json:"request_id,omitempty"`
IdempotencyKey string `json:"idempotency_key"`
CreatedAt time.Time `json:"created_at"`
}
type AgentCandidate struct {
ID int64 `json:"id"`
DocID string `json:"doc_id"`
InputSnapshotID int64 `json:"input_snapshot_id"`
OutputSnapshotID *int64 `json:"output_snapshot_id,omitempty"`
RunID string `json:"run_id"`
ProposalID string `json:"proposal_id,omitempty"`
Status string `json:"status"`
AuthorizedScope string `json:"authorized_scope,omitempty"`
QualityResult string `json:"quality_result,omitempty"`
ChangeReportID *int64 `json:"change_report_id,omitempty"`
ActorKind string `json:"actor_kind"`
ActorSnapshot ActorSnapshot `json:"actor_snapshot"`
CreatedAt time.Time `json:"created_at"`
DecidedAt *time.Time `json:"decided_at,omitempty"`
ID int64 `json:"id"`
DocID string `json:"doc_id"`
InputSnapshotID int64 `json:"input_snapshot_id"`
OutputSnapshotID *int64 `json:"output_snapshot_id,omitempty"`
RunID string `json:"run_id"`
ProposalID string `json:"proposal_id,omitempty"`
Status string `json:"status"`
AuthorizedScope string `json:"authorized_scope,omitempty"`
QualityResult string `json:"quality_result,omitempty"`
ChangeReportID *int64 `json:"change_report_id,omitempty"`
ActorKind string `json:"actor_kind"`
ActorSnapshot ActorSnapshot `json:"actor_snapshot"`
CreatedAt time.Time `json:"created_at"`
DecidedAt *time.Time `json:"decided_at,omitempty"`
}
type ChangeReport struct {
@@ -224,47 +226,48 @@ type DiffHunk struct {
}
type DiffResult struct {
FromSnapshotID int64 `json:"from_snapshot_id"`
ToSnapshotID int64 `json:"to_snapshot_id"`
FromSnapshotID int64 `json:"from_snapshot_id"`
ToSnapshotID int64 `json:"to_snapshot_id"`
Summary DiffSummary `json:"summary"`
Hunks []DiffHunk `json:"hunks"`
}
type DiffSummary struct {
Inserts int `json:"inserts"`
Deletes int `json:"deletes"`
Inserts int `json:"inserts"`
Deletes int `json:"deletes"`
Modifies int `json:"modifies"`
Moves int `json:"moves"`
Moves int `json:"moves"`
}
type DocRootPointers struct {
DocID string
Kind string // controlled|freeform
LedgerVersionID *int64
HeadSnapshotID *int64
HeadRevisionNo int
ReviewSnapshotID *int64
RevisionSchemaVer int
RowRevision int64
LifecycleState string
ContentSHA256 string
LegacyVersionID int // freeform only
WorkDir string
ProjectID int64
AuthorID *int64
DocID string
Kind string // controlled|freeform
LedgerVersionID *int64
HeadSnapshotID *int64
HeadRevisionNo int
ReviewSnapshotID *int64
RevisionSchemaVer int
RowRevision int64
LifecycleState string
ContentSHA256 string
LegacyVersionID int // freeform only
WorkDir string
ProjectID int64
AuthorID *int64
}
type RevisionState struct {
DocID string `json:"doc_id"`
CurrentVersionNo int `json:"current_version_no"`
VersionStatus string `json:"version_status"`
BaselineSnapshotID int64 `json:"baseline_snapshot_id"`
HeadSnapshotID int64 `json:"head_snapshot_id"`
HeadRevisionNo int `json:"head_revision_no"`
HeadSHA256 string `json:"head_sha256"`
ManualChangeCount int `json:"manual_change_count"`
Capabilities []string `json:"capabilities"`
ETag string `json:"etag"`
DocID string `json:"doc_id"`
CurrentVersionNo int `json:"current_version_no"`
VersionStatus string `json:"version_status"`
BaselineSnapshotID int64 `json:"baseline_snapshot_id"`
HeadSnapshotID int64 `json:"head_snapshot_id"`
HeadRevisionNo int `json:"head_revision_no"`
HeadSHA256 string `json:"head_sha256"`
ManualChangeCount int `json:"manual_change_count"`
Capabilities []string `json:"capabilities"`
ETag string `json:"etag"`
HeadBlockMap []BlockNode `json:"head_block_map,omitempty"`
}
type HumanRevisionRequest struct {
@@ -285,18 +288,18 @@ type HumanRevisionRequest struct {
}
type HumanRevisionResult struct {
State RevisionState `json:"state"`
Event ChangeEvent `json:"event"`
Overlay DiffResult `json:"manual_overlay"`
Conflict *ConflictInfo `json:"conflict,omitempty"`
State RevisionState `json:"state"`
Event ChangeEvent `json:"event"`
Overlay DiffResult `json:"manual_overlay"`
Conflict *ConflictInfo `json:"conflict,omitempty"`
}
type ConflictInfo struct {
Code string `json:"code"`
BaseSnapshotID int64 `json:"base_snapshot_id"`
CurrentSnapshotID int64 `json:"current_snapshot_id"`
CurrentRevisionNo int `json:"current_revision_no"`
CurrentSHA256 string `json:"current_sha256"`
Code string `json:"code"`
BaseSnapshotID int64 `json:"base_snapshot_id"`
CurrentSnapshotID int64 `json:"current_snapshot_id"`
CurrentRevisionNo int `json:"current_revision_no"`
CurrentSHA256 string `json:"current_sha256"`
}
type AgentCandidateRequest struct {
@@ -313,11 +316,11 @@ type AgentCandidateRequest struct {
}
type CommitAgentDecision struct {
Decision string // commit|reject
Confirmed bool // required when risk high/critical
ExpectedInputSHA string
ExpectedOutputSHA string
Reason string
ActorUserID int64
ActorSnapshot ActorSnapshot
Decision string // commit|reject
Confirmed bool // required when risk high/critical
ExpectedInputSHA string
ExpectedOutputSHA string
Reason string
ActorUserID int64
ActorSnapshot ActorSnapshot
}
+53 -12
View File
@@ -3,6 +3,7 @@ package server
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
@@ -20,14 +21,18 @@ type docRevisionRef struct {
LifecycleState string
Markdown string
Title string
AuthorID int64
}
func (s *Server) resolveDocRevisionRef(ctx context.Context, user *auth.UserAccount, docID string) (*docRevisionRef, error) {
if strings.TrimSpace(docID) == "" {
return nil, docrevision.ErrInvalidDocID
}
if s.svc != nil && s.svc.DocGov() != nil {
if doc, err := s.svc.DocGov().GetDocument(ctx, docID); err == nil && doc != nil {
ref := &docRevisionRef{
Kind: "controlled", ProjectID: doc.ProjectID, WorkDir: doc.WorkDir,
LifecycleState: string(doc.CurrentState),
LifecycleState: string(doc.CurrentState), AuthorID: doc.AuthorID,
}
if content, cErr := s.svc.DocGov().ReadDocumentContent(ctx, docID, s.dataRoot()); cErr == nil && content != nil {
ref.Markdown = content.Markdown
@@ -51,24 +56,60 @@ func (s *Server) resolveDocRevisionRef(ctx context.Context, user *auth.UserAccou
return &docRevisionRef{Kind: "orphan", LifecycleState: "drafting"}, nil
}
func (s *Server) authorizeDocRevision(ctx context.Context, user *auth.UserAccount, ref *docRevisionRef) error {
func (s *Server) authorizeDocRevision(ctx context.Context, user *auth.UserAccount, ref *docRevisionRef, write bool) error {
if user == nil || ref == nil {
return authz.ErrForbidden
}
if ref.Kind == "orphan" {
// Orphan upload: allow authenticated read/bootstrap only when client supplies content on PATCH.
return nil
}
if ref.ProjectID <= 0 {
return nil
}
if s.authorizer == nil {
if s.failClosedAuthz() {
if write {
return authz.ErrForbidden
}
return nil
return docrevision.ErrNotFound
}
return s.authorizer.RequireProjectAccessOrAdmin(ctx, ref.ProjectID, user.ID, s.isSystemAdminUser(ctx, user))
if ref.ProjectID > 0 {
if s.authorizer == nil {
if s.failClosedAuthz() {
return authz.ErrForbidden
}
} else if err := s.authorizer.RequireProjectAccessOrAdmin(ctx, ref.ProjectID, user.ID, s.isSystemAdminUser(ctx, user)); err != nil {
return err
}
} else if ref.Kind == "controlled" {
return authz.ErrForbidden
}
if write && ref.LifecycleState != "" && ref.LifecycleState != "drafting" {
return docrevision.ErrLifecycleLocked
}
if write && ref.Kind == "controlled" && ref.AuthorID > 0 && user.ID != ref.AuthorID && !s.isSystemAdminUser(ctx, user) {
return docrevision.ErrForbidden
}
return nil
}
func (s *Server) requireDocRevisionAccess(w http.ResponseWriter, r *http.Request, write bool) (*auth.UserAccount, string, *docRevisionRef, bool) {
user, ok := s.requirePrincipal(w, r)
if !ok {
return nil, "", nil, false
}
docID := strings.TrimSpace(r.PathValue("doc_id"))
if docID == "" {
writeDocRevisionError(w, docrevision.ErrInvalidDocID)
return nil, "", nil, false
}
ref, err := s.resolveDocRevisionRef(r.Context(), user, docID)
if err != nil {
writeDocRevisionError(w, err)
return nil, "", nil, false
}
if err := s.authorizeDocRevision(r.Context(), user, ref, write); err != nil {
if errors.Is(err, authz.ErrForbidden) || errors.Is(err, authz.ErrUnauthenticated) {
writeAuthzError(w, err)
return nil, "", nil, false
}
writeDocRevisionError(w, err)
return nil, "", nil, false
}
return user, docID, ref, true
}
func (s *Server) dataRoot() string {
@@ -0,0 +1,97 @@
package server
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"chat-agent-system/internal/docrevision"
)
func TestDocRevision_UnauthenticatedAndUnknownOrphan(t *testing.T) {
srv := newTestServer(t)
reads := []string{
"/api/v1/documents/doc-upload-unknown/revision-state",
"/api/v1/documents/doc-upload-unknown/versions",
"/api/v1/documents/doc-upload-unknown/versions/1",
"/api/v1/documents/doc-upload-unknown/diff?from_version=1&to_version=1",
"/api/v1/documents/doc-upload-unknown/manual-overlay",
"/api/v1/documents/doc-upload-unknown/agent-candidates/1",
"/api/v1/documents/doc-upload-unknown/change-reports/1",
}
// Handler-level: no principal in context must 401 (in-memory Routes() may inject legacy user 1).
for _, h := range []struct {
name string
fn http.HandlerFunc
}{
{"revision-state", srv.handleDocRevisionState},
{"versions", srv.handleDocRevisionListVersions},
{"overlay", srv.handleDocRevisionManualOverlay},
{"candidate", srv.handleDocRevisionGetCandidate},
{"report", srv.handleDocRevisionGetReport},
{"decision", srv.handleDocRevisionCandidateDecision},
} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/doc-upload-unknown/revision-state", nil)
w := httptest.NewRecorder()
h.fn(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("%s without principal want 401 got %d %s", h.name, w.Code, w.Body.String())
}
}
token := loginTestAdmin(t, srv)
for _, path := range reads {
req := httptest.NewRequest(http.MethodGet, path, nil)
withBearerAuth(req, token)
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("%s unknown orphan want 404 got %d %s", path, w.Code, w.Body.String())
}
}
patch := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/doc-upload-unknown/content", bytes.NewReader([]byte(`{"markdown":"# x\n"}`)))
withBearerAuth(patch, token)
patch.Header.Set("Idempotency-Key", "orphan-k1")
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, patch)
if w.Code != http.StatusForbidden {
t.Fatalf("unknown orphan PATCH want 403 got %d %s", w.Code, w.Body.String())
}
dec := httptest.NewRequest(http.MethodPost, "/api/v1/documents/doc-upload-unknown/agent-candidates/1/decision", bytes.NewReader([]byte(`{"decision":"reject"}`)))
withBearerAuth(dec, token)
w2 := httptest.NewRecorder()
srv.Routes().ServeHTTP(w2, dec)
if w2.Code != http.StatusForbidden {
t.Fatalf("unknown orphan decision want 403 got %d %s", w2.Code, w2.Body.String())
}
}
func TestDocRevision_ControlledDocumentRoutes(t *testing.T) {
srv := newTestServer(t)
ctx := context.Background()
docID := "doc_proj1_intake"
if _, err := srv.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底表", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil {
t.Fatal(err)
}
if _, err := srv.svc.DocRevision().OpenTemplateVersion(ctx, docID, "# 摸底\n\n正文\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, ""); err != nil {
t.Fatal(err)
}
token := loginTestAdmin(t, srv)
for _, path := range []string{
"/api/v1/documents/" + docID + "/revision-state",
"/api/v1/documents/" + docID + "/versions",
"/api/v1/documents/" + docID + "/manual-overlay",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
withBearerAuth(req, token)
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("%s want 200 got %d %s", path, w.Code, w.Body.String())
}
}
}
+61 -49
View File
@@ -7,6 +7,7 @@ import (
"strconv"
"strings"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/freedoc"
)
@@ -32,25 +33,15 @@ func (s *Server) docRev() *docrevision.Service {
}
func (s *Server) handleDocRevisionState(w http.ResponseWriter, r *http.Request) {
user, ok := s.requirePrincipal(w, r)
user, docID, ref, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
docID := r.PathValue("doc_id")
rev := s.docRev()
if rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
ref, err := s.resolveDocRevisionRef(r.Context(), user, docID)
if err != nil {
writeDocRevisionError(w, err)
return
}
if err := s.authorizeDocRevision(r.Context(), user, ref); err != nil {
writeAuthzError(w, err)
return
}
st, err := s.bootstrapRevisionIfNeeded(r.Context(), user, docID, ref)
if err != nil {
writeDocRevisionError(w, err)
@@ -60,7 +51,8 @@ func (s *Server) handleDocRevisionState(w http.ResponseWriter, r *http.Request)
}
func (s *Server) handleDocRevisionListVersions(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requirePrincipal(w, r); !ok {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
@@ -68,7 +60,7 @@ func (s *Server) handleDocRevisionListVersions(w http.ResponseWriter, r *http.Re
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
items, err := rev.ListVersions(r.Context(), r.PathValue("doc_id"))
items, err := rev.ListVersions(r.Context(), docID)
if err != nil {
writeDocRevisionError(w, err)
return
@@ -77,7 +69,8 @@ func (s *Server) handleDocRevisionListVersions(w http.ResponseWriter, r *http.Re
}
func (s *Server) handleDocRevisionGetVersion(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requirePrincipal(w, r); !ok {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
@@ -90,7 +83,7 @@ func (s *Server) handleDocRevisionGetVersion(w http.ResponseWriter, r *http.Requ
if view == "" {
view = "final"
}
md, sha, err := rev.GetVersionContent(r.Context(), r.PathValue("doc_id"), vno, view)
md, sha, err := rev.GetVersionContent(r.Context(), docID, vno, view)
if err != nil {
writeDocRevisionError(w, err)
return
@@ -102,7 +95,8 @@ func (s *Server) handleDocRevisionGetVersion(w http.ResponseWriter, r *http.Requ
}
func (s *Server) handleDocRevisionDiff(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requirePrincipal(w, r); !ok {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
@@ -120,7 +114,7 @@ func (s *Server) handleDocRevisionDiff(w http.ResponseWriter, r *http.Request) {
if toView == "" {
toView = "final"
}
diff, err := rev.DiffVersions(r.Context(), r.PathValue("doc_id"), fromNo, toNo, fromView, toView)
diff, err := rev.DiffVersions(r.Context(), docID, fromNo, toNo, fromView, toView)
if err != nil {
writeDocRevisionError(w, err)
return
@@ -129,7 +123,8 @@ func (s *Server) handleDocRevisionDiff(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleDocRevisionManualOverlay(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requirePrincipal(w, r); !ok {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
@@ -137,7 +132,7 @@ func (s *Server) handleDocRevisionManualOverlay(w http.ResponseWriter, r *http.R
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
diff, err := rev.ManualOverlay(r.Context(), r.PathValue("doc_id"))
diff, err := rev.ManualOverlay(r.Context(), docID)
if err != nil {
writeDocRevisionError(w, err)
return
@@ -146,7 +141,7 @@ func (s *Server) handleDocRevisionManualOverlay(w http.ResponseWriter, r *http.R
}
func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Request) {
user, ok := s.requirePrincipal(w, r)
user, docID, ref, ok := s.requireDocRevisionAccess(w, r, true)
if !ok {
return
}
@@ -155,10 +150,10 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
docID := r.PathValue("doc_id")
var body struct {
Markdown string `json:"markdown"`
EditSessionID string `json:"edit_session_id"`
Markdown string `json:"markdown"`
EditSessionID string `json:"edit_session_id"`
ClientBlockMap []docrevision.BlockNode `json:"client_block_map"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid json"})
@@ -183,16 +178,6 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
}
}
ref, err := s.resolveDocRevisionRef(r.Context(), user, docID)
if err != nil {
writeDocRevisionError(w, err)
return
}
if err := s.authorizeDocRevision(r.Context(), user, ref); err != nil {
writeAuthzError(w, err)
return
}
lifecycle := "drafting"
workDir := ""
projectID := int64(0)
@@ -202,10 +187,21 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
workDir = ref.WorkDir
projectID = ref.ProjectID
kind = ref.Kind
if kind == "orphan" {
kind = "freeform"
}
if kind == "controlled" && s.svc != nil && s.svc.DocGov() != nil {
if _, err := s.svc.DocGov().SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{
DocID: docID,
ActorID: user.ID,
ActorRole: docgov.RoleAuthor,
ActorName: user.Name,
MarkdownBody: body.Markdown,
}); err != nil {
writeDocGovError(w, err)
return
}
}
if _, stErr := rev.GetRevisionState(r.Context(), docID); stErr != nil {
md := body.Markdown
if ref != nil && strings.TrimSpace(ref.Markdown) != "" {
@@ -215,17 +211,21 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
writeDocRevisionError(w, docrevision.ErrNeedsContent)
return
}
_, _ = rev.EnsureBootstrap(r.Context(), docrevision.BootstrapRequest{
if _, err := rev.EnsureBootstrap(r.Context(), docrevision.BootstrapRequest{
DocID: docID, Kind: kind, Markdown: md, ProjectID: projectID, WorkDir: workDir,
LifecycleState: lifecycle,
Actor: docrevision.ActorSnapshot{DisplayName: user.Name, Role: "author", Duty: "author"},
})
}); err != nil {
writeDocRevisionError(w, err)
return
}
}
res, err := rev.CommitHumanRevision(r.Context(), docrevision.HumanRevisionRequest{
DocID: docID, Markdown: body.Markdown, ExpectedHeadSnapshot: expectedHead, ExpectedRevisionNo: expectedRev,
IdempotencyKey: idemp, EditSessionID: body.EditSessionID, ActorUserID: user.ID,
ActorSnapshot: docrevision.ActorSnapshot{DisplayName: user.Name, Role: "author", Duty: "author"},
ActorSnapshot: docrevision.ActorSnapshot{DisplayName: user.Name, Role: "author", Duty: "author"},
LifecycleState: lifecycle, WorkDir: workDir, ProjectID: projectID, AllowCreateBaseline: true,
ClientBlockMap: body.ClientBlockMap,
})
if errors.Is(err, docrevision.ErrHeadConflict) && res != nil && res.Conflict != nil {
writeJSONResponse(w, http.StatusConflict, map[string]any{
@@ -237,25 +237,34 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
writeDocRevisionError(w, err)
return
}
// Project to the user-visible fact source.
if kind == "controlled" || workDir != "" {
_ = rev.ProjectOutputMarkdown(docID, s.dataRoot())
if err := rev.ProjectOutputMarkdown(docID, s.dataRoot()); err != nil {
writeDocRevisionError(w, err)
return
}
if s.svc != nil && s.svc.DocGov() != nil {
_ = s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, res.State.HeadSHA256, false)
if err := s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, res.State.HeadSHA256, false); err != nil {
writeDocGovError(w, err)
return
}
}
}
if kind == "freeform" && s.svc != nil && s.svc.FreeformDoc() != nil {
md := body.Markdown
_, _ = s.svc.FreeformDoc().UpdateDocument(r.Context(), int(user.ID), docID, freedoc.UpdateFreeformDocRequest{
if _, err := s.svc.FreeformDoc().UpdateDocument(r.Context(), int(user.ID), docID, freedoc.UpdateFreeformDocRequest{
ContentMarkdown: &md,
ChangeSummary: "revision ledger human save",
})
}); err != nil {
writeJSONResponse(w, http.StatusConflict, map[string]any{"error": err.Error()})
return
}
}
writeJSONResponse(w, http.StatusOK, res)
}
func (s *Server) handleDocRevisionGetCandidate(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requirePrincipal(w, r); !ok {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
@@ -264,7 +273,7 @@ func (s *Server) handleDocRevisionGetCandidate(w http.ResponseWriter, r *http.Re
return
}
cid, _ := strconv.ParseInt(r.PathValue("candidate_id"), 10, 64)
c, err := rev.GetCandidate(r.Context(), r.PathValue("doc_id"), cid)
c, err := rev.GetCandidate(r.Context(), docID, cid)
if err != nil {
writeDocRevisionError(w, err)
return
@@ -273,7 +282,7 @@ func (s *Server) handleDocRevisionGetCandidate(w http.ResponseWriter, r *http.Re
}
func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *http.Request) {
user, ok := s.requirePrincipal(w, r)
user, docID, _, ok := s.requireDocRevisionAccess(w, r, true)
if !ok {
return
}
@@ -294,7 +303,7 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht
writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid json"})
return
}
st, err := rev.CommitAgentVersion(r.Context(), cid, docrevision.CommitAgentDecision{
st, err := rev.CommitAgentVersion(r.Context(), docID, cid, docrevision.CommitAgentDecision{
Decision: body.Decision, Confirmed: body.Confirmed,
ExpectedInputSHA: body.ExpectedInputSHA, ExpectedOutputSHA: body.ExpectedOutputSHA,
Reason: body.Reason, ActorUserID: user.ID,
@@ -308,7 +317,8 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht
}
func (s *Server) handleDocRevisionGetReport(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requirePrincipal(w, r); !ok {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
@@ -317,7 +327,7 @@ func (s *Server) handleDocRevisionGetReport(w http.ResponseWriter, r *http.Reque
return
}
rid, _ := strconv.ParseInt(r.PathValue("report_id"), 10, 64)
rep, err := rev.GetChangeReport(r.Context(), r.PathValue("doc_id"), rid)
rep, err := rev.GetChangeReport(r.Context(), docID, rid)
if err != nil {
writeDocRevisionError(w, err)
return
@@ -351,6 +361,8 @@ func (s *Server) handlePatchNotification(w http.ResponseWriter, r *http.Request)
func writeDocRevisionError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, docrevision.ErrInvalidDocID):
writeJSONResponse(w, http.StatusBadRequest, map[string]any{"code": docrevision.ErrCodeInvalidDocID, "error": err.Error()})
case errors.Is(err, docrevision.ErrNotFound):
writeJSONResponse(w, http.StatusNotFound, map[string]any{"code": docrevision.ErrCodeNotFound, "error": err.Error()})
case errors.Is(err, docrevision.ErrNeedsContent):
+22 -16
View File
@@ -22,6 +22,7 @@ import { fetchChatSessions, upsertChatSession } from './api/chatSessions';
import { listProjectWorkflowDocuments } from './api/projectDocuments';
import { patchDocumentContent, getRevisionState } from './api/documentRevision';
import type { DiffResult, OverlayMode } from './features/documentRevision/types';
import { applyHeadBlockMap, astToClientBlockMap } from './features/documentRevision/overlayMatch';
import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow';
import {
addProjectMember,
@@ -3134,7 +3135,7 @@ export default function App() {
]);
};
// 块操作:更新本地 AST 后经 revision ledger 持久化(服务端身份为准)
// 块操作:先 PATCH,成功才提交 AST;失败保留编辑器并明确提示
const handleSaveBlockEdit = async (updated: DocumentBlock) => {
const children = docAST.rootBlock.children || [];
const idx = children.findIndex((c) => c.id === updated.id);
@@ -3142,22 +3143,21 @@ export default function App() {
setEditingBlock(null);
return;
}
children[idx] = updated;
recomputeNumbering(docAST.rootBlock);
const nextAST = { ...docAST };
setDocAST(nextAST);
setDocCache((prev) => ({ ...prev, [activeDocId]: nextAST }));
setEditingBlock(null);
setDocRevConflict(null);
const nextRoot = {
...docAST.rootBlock,
children: children.map((c, i) => (i === idx ? updated : { ...c })),
};
recomputeNumbering(nextRoot);
const nextAST = { ...docAST, rootBlock: nextRoot };
const target = deliverables.find((d) => d.docId === activeDocId);
// Uploaded binary preview tabs stay file-only; structured freeform/controlled both persist.
if (
target &&
(target.sourceType === 'uploaded_file' || Boolean(target.fileMeta)) &&
target.governanceMode === 'freeform' &&
!docAST.rootBlock?.children?.length
) {
setEditingBlock(null);
return;
}
try {
@@ -3170,7 +3170,7 @@ export default function App() {
if (err.code === 'DOCUMENT_REVISION_NEEDS_CONTENT') {
/* first PATCH bootstraps from markdown body */
} else if (err.code && err.code !== 'DOCUMENT_REVISION_NOT_FOUND') {
/* continue; PATCH AllowCreateBaseline */
throw e;
}
}
const markdown = blockASTToMarkdown(nextAST);
@@ -3179,19 +3179,25 @@ export default function App() {
`edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const res = await patchDocumentContent(
activeDocId,
{ markdown, edit_session_id: `ui-${currentUser.id}` },
{
markdown,
edit_session_id: `ui-${currentUser.id}`,
client_block_map: astToClientBlockMap(nextAST),
},
{ etag, idempotencyKey },
);
const aligned = applyHeadBlockMap(nextAST, res.state?.head_block_map);
setDocAST(aligned);
setDocCache((prev) => ({ ...prev, [activeDocId]: aligned }));
setEditingBlock(null);
setDocRevConflict(null);
setDocRevOverlay(res.manual_overlay || null);
if (res.state?.etag) {
/* overlay refresh after save */
}
} catch (e) {
const err = e as Error & { code?: string; conflict?: { current_revision_no?: number } };
if (err.code === 'DOCUMENT_HEAD_CONFLICT') {
setDocRevConflict('文档已被他人更新。请刷新后重做,系统不会自动覆盖你的副本。');
setDocRevConflict('保存失败:文档已被他人更新。编辑器仍保留你的未保存修改,请刷新对照后重试。');
} else {
setDocRevConflict(err.message || '保存失败');
setDocRevConflict(`保存失败:${err.message || '请求未成功'}。编辑器仍保留当前修改,请重试。`);
}
}
};
+2 -5
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { DocumentAST, DocumentBlock } from '../../types/block';
import { UserRole } from '../../types/docgov';
import type { DiffHunk, OverlayMode } from '../../features/documentRevision/types';
import { hunkMatchesBlock } from '../../features/documentRevision/overlayMatch';
import { HeadingBlockView } from './blocks/HeadingBlockView';
import { ParagraphBlockView } from './blocks/ParagraphBlockView';
import { TableBlockView } from './blocks/TableBlockView';
@@ -138,11 +139,7 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
const isHighlighted = !!highlightedBlockId && highlightedBlockId === block.id;
const commentCount = block.comments?.length || 0;
const hunk =
overlayMode !== 'pure'
? overlayHunks.find(
(h) => h.block_id_after === block.id || h.block_id_before === block.id,
)
: undefined;
overlayMode !== 'pure' ? overlayHunks.find((h) => hunkMatchesBlock(h, block)) : undefined;
const manualMark =
!!hunk && overlayMode === 'manual' && (!hunk.actor_kind || hunk.actor_kind === 'human');
const allMark = !!hunk && overlayMode === 'all';
@@ -0,0 +1,57 @@
import type { BlockNode, DiffHunk } from './types';
import type { DocumentAST, DocumentBlock } from '../../types/block';
function snippet(text: string | undefined, n = 80): string {
return (text || '').replace(/\s+/g, ' ').trim().slice(0, n);
}
export function hunkMatchesBlock(hunk: DiffHunk, block: DocumentBlock): boolean {
if (!hunk || !block) return false;
if (hunk.block_id_after === block.id || hunk.block_id_before === block.id) {
return true;
}
if (hunk.section) {
const heading = (block.level ? block.content : '') || '';
if (heading.includes(hunk.section) || hunk.section.includes(heading) || block.content.includes(hunk.section)) {
return true;
}
}
const after = snippet(hunk.after_text);
const before = snippet(hunk.before_text);
const content = snippet(block.content);
if (after && content && (content.includes(after) || after.includes(content))) {
return true;
}
if (before && content && (content.includes(before) || before.includes(content))) {
return true;
}
return false;
}
export function astToClientBlockMap(ast: DocumentAST): BlockNode[] {
return (ast.rootBlock.children || []).map((b, i) => ({
id: b.id,
type: b.type,
start_offset: 0,
end_offset: Math.max(0, (b.content || '').length),
heading_path: b.type === 'heading' ? b.content : undefined,
order: i,
}));
}
export function applyHeadBlockMap(ast: DocumentAST, map?: BlockNode[]): DocumentAST {
if (!map || map.length === 0) {
return ast;
}
const children = ast.rootBlock.children || [];
if (children.length !== map.length) {
return ast;
}
return {
...ast,
rootBlock: {
...ast.rootBlock,
children: children.map((c, i) => (map[i]?.id ? { ...c, id: map[i].id } : c)),
},
};
}
@@ -2,6 +2,8 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { DiffHunk, OverlayMode } from './types';
import { DOC_REV_ERROR } from './types';
import { applyHeadBlockMap, hunkMatchesBlock } from './overlayMatch';
import type { DocumentAST, DocumentBlock } from '../../types/block';
function decorationFor(mode: OverlayMode, hunk?: DiffHunk): 'none' | 'manual' | 'agent' {
if (mode === 'pure' || !hunk) return 'none';
@@ -34,4 +36,39 @@ describe('document revision overlay modes', () => {
it('exposes needs-content error code for empty orphan docs', () => {
assert.equal(DOC_REV_ERROR.NEEDS_CONTENT, 'DOCUMENT_REVISION_NEEDS_CONTENT');
});
it('matches overlay hunks by section or text when ids differ', () => {
const block: DocumentBlock = { id: 'blk_client', type: 'paragraph', content: 'hello edited body' };
const hunk: DiffHunk = {
id: 'h1',
kind: 'modify',
block_id_after: 'blk_server',
after_text: 'hello edited body',
section: '概述',
};
assert.equal(hunkMatchesBlock(hunk, block), true);
assert.equal(hunkMatchesBlock({ ...hunk, after_text: '', section: '' }, block), false);
});
it('aligns client block ids from head_block_map by order', () => {
const ast = {
docId: 'd1',
title: 't',
taskKey: 'intake',
version: '1',
lifecycleState: 'drafting',
author: 'a',
reviewer: 'r',
approver: 'p',
updatedAt: '',
rootBlock: {
id: 'root',
type: 'document',
content: '',
children: [{ id: 'blk_rand', type: 'paragraph', content: 'x' }],
},
} as DocumentAST;
const aligned = applyHeadBlockMap(ast, [{ id: 'blk_aa', type: 'paragraph', start_offset: 0, end_offset: 1, order: 0 }]);
assert.equal(aligned.rootBlock.children?.[0].id, 'blk_aa');
});
});
@@ -37,6 +37,7 @@ export interface RevisionState {
manual_change_count: number;
capabilities: string[];
etag: string;
head_block_map?: BlockNode[];
}
export interface DiffHunk {
@@ -98,6 +99,7 @@ export interface ConflictInfo {
export interface PatchContentBody {
markdown: string;
edit_session_id?: string;
client_block_map?: BlockNode[];
client_block_map_version?: number;
}