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:
@@ -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
@@ -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 实战验收
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user