feat(api): authorize revision routes and project fact-source saves

Resolve controlled/freeform/orphan docs, bootstrap on GET, write output.md or freeform content on PATCH, and wire lineage impacts for agent candidates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 08:48:32 +00:00
co-authored by Cursor
parent ed1e232022
commit 46a1fcc945
3 changed files with 228 additions and 27 deletions
+29 -5
View File
@@ -92,6 +92,7 @@ func NewService(cfg *config.Config) (*Service, error) {
}
docGov := docgov.NewGovernanceEngine(pool)
docRev := docrevision.NewService(pool)
docRev.DataDir = cfg.DataDir
freeDoc := freedoc.NewService(pool, ctxMgr)
freeDoc.SetDocRevision(docRev)
pwStore := projectworkflow.NewStore(pool)
@@ -102,6 +103,33 @@ func NewService(cfg *config.Config) (*Service, error) {
}
catalog := workflowdef.NewCatalog(context.Background(), wfStore, fallbackCatalog)
pwSvc := projectworkflow.NewService(pwStore, catalog)
docRev.ImpactResolver = func(ctx context.Context, docID string) []docrevision.DocImpact {
if pwSvc == nil {
return nil
}
lineages, err := pwSvc.Store().GetLineagesForUpstream(ctx, docID)
if err != nil || len(lineages) == 0 {
return nil
}
down := make([]struct {
DocID string
Reason string
}, 0, len(lineages))
for _, ln := range lineages {
if ln == nil {
continue
}
reason := ln.StaleReason
if reason == "" {
reason = "上游文档内容变更,下游需复核"
}
down = append(down, struct {
DocID string
Reason string
}{DocID: ln.DownstreamDocID, Reason: reason})
}
return docrevision.ImpactFromLineage(down)
}
svc := &Service{
cfg: cfg,
@@ -721,14 +749,10 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
_, _ = s.docRev.OpenTemplateVersion(ctx, docID, res.MarkdownBody, actor, req.ProjectID, res.WorkDir)
} else {
st, _ := s.docRev.GetRevisionState(ctx, docID)
reportJSON := `{"schema_version":1,"summary":["智能体交付物已生成"],"risk":{"level":"medium","items":[]},"document_impacts":[],"open_questions":[]}`
if req.TaskKey == "system_reqs" {
reportJSON = `{"schema_version":1,"summary":["系统需求规格已更新"],"risk":{"level":"high","items":[{"category":"verification","description":"可能影响下游验证"}]},"document_impacts":[],"open_questions":[]}`
}
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: reportJSON,
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) {
+135
View File
@@ -0,0 +1,135 @@
package server
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/freedoc"
)
type docRevisionRef struct {
Kind string // controlled|freeform|orphan
ProjectID int64
WorkDir string
LifecycleState string
Markdown string
Title string
}
func (s *Server) resolveDocRevisionRef(ctx context.Context, user *auth.UserAccount, docID string) (*docRevisionRef, error) {
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),
}
if content, cErr := s.svc.DocGov().ReadDocumentContent(ctx, docID, s.dataRoot()); cErr == nil && content != nil {
ref.Markdown = content.Markdown
} else if doc.WorkDir != "" {
md, _ := readOutputMarkdown(s.dataRoot(), doc.WorkDir)
ref.Markdown = md
}
return ref, nil
}
}
if s.svc != nil && s.svc.FreeformDoc() != nil && user != nil {
if detail, err := s.svc.FreeformDoc().GetDocument(ctx, int(user.ID), docID); err == nil && detail != nil {
return &docRevisionRef{
Kind: "freeform", ProjectID: int64(detail.Document.ProjectID),
LifecycleState: "drafting", Markdown: detail.Content, Title: detail.Document.Title,
}, nil
} else if errors.Is(err, freedoc.ErrForbidden) {
return nil, docrevision.ErrForbidden
}
}
return &docRevisionRef{Kind: "orphan", LifecycleState: "drafting"}, nil
}
func (s *Server) authorizeDocRevision(ctx context.Context, user *auth.UserAccount, ref *docRevisionRef) 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() {
return authz.ErrForbidden
}
return nil
}
return s.authorizer.RequireProjectAccessOrAdmin(ctx, ref.ProjectID, user.ID, s.isSystemAdminUser(ctx, user))
}
func (s *Server) dataRoot() string {
if s != nil && s.cfg != nil && s.cfg.DataDir != "" {
return s.cfg.DataDir
}
return "."
}
func readOutputMarkdown(dataRoot, workDir string) (string, error) {
path := workDir
if !filepath.IsAbs(path) {
path = filepath.Join(dataRoot, workDir)
}
b, err := os.ReadFile(filepath.Join(path, "output.md"))
if err != nil {
return "", err
}
return string(b), nil
}
func (s *Server) bootstrapRevisionIfNeeded(ctx context.Context, user *auth.UserAccount, docID string, ref *docRevisionRef) (*docrevision.RevisionState, error) {
rev := s.docRev()
if rev == nil {
return nil, errors.New("docrevision unavailable")
}
st, err := rev.GetRevisionState(ctx, docID)
if err == nil {
return st, nil
}
if !errors.Is(err, docrevision.ErrNotFound) {
return nil, err
}
md := ""
if ref != nil {
md = ref.Markdown
}
if strings.TrimSpace(md) == "" {
return nil, docrevision.ErrNeedsContent
}
kind := "controlled"
life := "drafting"
var projectID int64
workDir := ""
if ref != nil {
if ref.Kind != "" {
kind = ref.Kind
}
if ref.Kind == "orphan" {
kind = "freeform"
}
life = ref.LifecycleState
projectID = ref.ProjectID
workDir = ref.WorkDir
}
actor := docrevision.ActorSnapshot{DisplayName: "bootstrap", Role: "system"}
if user != nil {
actor = docrevision.ActorSnapshot{DisplayName: user.Name, Role: "author", Duty: "author"}
}
return rev.EnsureBootstrap(ctx, docrevision.BootstrapRequest{
DocID: docID, Kind: kind, Markdown: md, ProjectID: projectID,
WorkDir: workDir, LifecycleState: life, Actor: actor,
})
}
+64 -22
View File
@@ -8,6 +8,7 @@ import (
"strings"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/freedoc"
)
func (s *Server) registerDocumentRevisionRoutes(mux *http.ServeMux) {
@@ -35,14 +36,22 @@ func (s *Server) handleDocRevisionState(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
_ = user
docID := r.PathValue("doc_id")
rev := s.docRev()
if rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
st, err := rev.GetRevisionState(r.Context(), docID)
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)
return
@@ -173,27 +182,45 @@ 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)
if s.svc != nil && s.svc.DocGov() != nil {
if doc, err := s.svc.DocGov().GetDocument(r.Context(), docID); err == nil && doc != nil {
lifecycle = string(doc.CurrentState)
workDir = doc.WorkDir
projectID = doc.ProjectID
// Ensure ledger baseline exists from current content when first edit arrives.
if st, stErr := rev.GetRevisionState(r.Context(), docID); stErr != nil {
md := body.Markdown
if content, cErr := s.svc.DocGov().ReadDocumentContent(r.Context(), docID, "."); cErr == nil && content != nil && content.Markdown != "" {
md = content.Markdown
}
_, _ = rev.OpenTemplateVersion(r.Context(), docID, md, docrevision.ActorSnapshot{
DisplayName: user.Name, Role: "author", Duty: "author",
}, projectID, workDir)
_ = st
}
kind := "controlled"
if ref != nil {
lifecycle = ref.LifecycleState
workDir = ref.WorkDir
projectID = ref.ProjectID
kind = ref.Kind
if kind == "orphan" {
kind = "freeform"
}
}
if _, stErr := rev.GetRevisionState(r.Context(), docID); stErr != nil {
md := body.Markdown
if ref != nil && strings.TrimSpace(ref.Markdown) != "" {
md = ref.Markdown
}
if strings.TrimSpace(md) == "" {
writeDocRevisionError(w, docrevision.ErrNeedsContent)
return
}
_, _ = 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"},
})
}
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,
@@ -210,10 +237,19 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
writeDocRevisionError(w, err)
return
}
_ = rev.ProjectOutputMarkdown(docID, ".")
// Compatibility projection into docgov current hash.
if s.svc != nil && s.svc.DocGov() != nil {
_ = s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, res.State.HeadSHA256, false)
// Project to the user-visible fact source.
if kind == "controlled" || workDir != "" {
_ = rev.ProjectOutputMarkdown(docID, s.dataRoot())
if s.svc != nil && s.svc.DocGov() != nil {
_ = s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, res.State.HeadSHA256, false)
}
}
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{
ContentMarkdown: &md,
ChangeSummary: "revision ledger human save",
})
}
writeJSONResponse(w, http.StatusOK, res)
}
@@ -317,6 +353,12 @@ func writeDocRevisionError(w http.ResponseWriter, err error) {
switch {
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):
writeJSONResponse(w, http.StatusConflict, map[string]any{
"code": docrevision.ErrCodeNeedsContent,
"error": err.Error(),
"hint": "文档尚无服务端正文事实源;请先保存正文或注册自由文档后再读取版本账本",
})
case errors.Is(err, docrevision.ErrHeadConflict):
writeJSONResponse(w, http.StatusConflict, map[string]any{"code": docrevision.ErrCodeHeadConflict, "error": err.Error()})
case errors.Is(err, docrevision.ErrLifecycleLocked):