diff --git a/.doc/audit/2026-09-13-document-revision-runtime-404.md b/.doc/audit/2026-09-13-document-revision-runtime-404.md index 6a738017..cab04c4a 100644 --- a/.doc/audit/2026-09-13-document-revision-runtime-404.md +++ b/.doc/audit/2026-09-13-document-revision-runtime-404.md @@ -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 不落库。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 96866dda..df4af520 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -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 并行中) ## 焦点 diff --git a/.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md b/.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md index 56bcc0bf..76b84942 100644 --- a/.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md +++ b/.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md @@ -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. 环境前置(集成槽位占用后执行) diff --git a/.doc/ops/short-term/2026-09-13-document-revision-runtime.md b/.doc/ops/short-term/2026-09-13-document-revision-runtime.md index cc2f271e..0ba9c3e9 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-runtime.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-runtime.md @@ -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 实战验收 diff --git a/internal/bizdev/doc_id_test.go b/internal/bizdev/doc_id_test.go index 778753ac..0c45faa8 100644 --- a/internal/bizdev/doc_id_test.go +++ b/internal/bizdev/doc_id_test.go @@ -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) + } +} diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 8472da13..2b9c1992 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -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, + }) + } } } } diff --git a/internal/docgov/lease.go b/internal/docgov/lease.go index d1f777c7..779a5442 100644 --- a/internal/docgov/lease.go +++ b/internal/docgov/lease.go @@ -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) diff --git a/internal/docrevision/contract_test.go b/internal/docrevision/contract_test.go index d706af45..fc239d92 100644 --- a/internal/docrevision/contract_test.go +++ b/internal/docrevision/contract_test.go @@ -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, }) diff --git a/internal/docrevision/invalid_docid_test.go b/internal/docrevision/invalid_docid_test.go new file mode 100644 index 00000000..96ed4dba --- /dev/null +++ b/internal/docrevision/invalid_docid_test.go @@ -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) + } +} diff --git a/internal/docrevision/pg_mutate.go b/internal/docrevision/pg_mutate.go index 09eda726..24be7374 100644 --- a/internal/docrevision/pg_mutate.go +++ b/internal/docrevision/pg_mutate.go @@ -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 +} diff --git a/internal/docrevision/pg_persist_test.go b/internal/docrevision/pg_persist_test.go index 75ca261a..1158bd0f 100644 --- a/internal/docrevision/pg_persist_test.go +++ b/internal/docrevision/pg_persist_test.go @@ -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) + } } diff --git a/internal/docrevision/service.go b/internal/docrevision/service.go index 107a7102..e51fd4e5 100644 --- a/internal/docrevision/service.go +++ b/internal/docrevision/service.go @@ -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 { diff --git a/internal/docrevision/types.go b/internal/docrevision/types.go index deb15b56..86a530c0 100644 --- a/internal/docrevision/types.go +++ b/internal/docrevision/types.go @@ -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 } diff --git a/internal/server/document_revision_access.go b/internal/server/document_revision_access.go index e2691db5..974cd85a 100644 --- a/internal/server/document_revision_access.go +++ b/internal/server/document_revision_access.go @@ -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 { diff --git a/internal/server/document_revision_authz_test.go b/internal/server/document_revision_authz_test.go new file mode 100644 index 00000000..cfe61e9a --- /dev/null +++ b/internal/server/document_revision_authz_test.go @@ -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()) + } + } +} diff --git a/internal/server/document_revision_handlers.go b/internal/server/document_revision_handlers.go index ec18c94b..b28aee78 100644 --- a/internal/server/document_revision_handlers.go +++ b/internal/server/document_revision_handlers.go @@ -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): diff --git a/web/src/App.tsx b/web/src/App.tsx index 6260b04b..bed69bb8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 || '请求未成功'}。编辑器仍保留当前修改,请重试。`); } } }; diff --git a/web/src/components/doc/BlockRenderer.tsx b/web/src/components/doc/BlockRenderer.tsx index ec7abb91..1a7d8d31 100644 --- a/web/src/components/doc/BlockRenderer.tsx +++ b/web/src/components/doc/BlockRenderer.tsx @@ -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 = ({ 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'; diff --git a/web/src/features/documentRevision/overlayMatch.ts b/web/src/features/documentRevision/overlayMatch.ts new file mode 100644 index 00000000..4494a760 --- /dev/null +++ b/web/src/features/documentRevision/overlayMatch.ts @@ -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)), + }, + }; +} diff --git a/web/src/features/documentRevision/overlayMode.test.ts b/web/src/features/documentRevision/overlayMode.test.ts index 18434243..c3ba338a 100644 --- a/web/src/features/documentRevision/overlayMode.test.ts +++ b/web/src/features/documentRevision/overlayMode.test.ts @@ -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'); + }); }); diff --git a/web/src/features/documentRevision/types.ts b/web/src/features/documentRevision/types.ts index 45b5c59b..3d1379d3 100644 --- a/web/src/features/documentRevision/types.ts +++ b/web/src/features/documentRevision/types.ts @@ -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; }