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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user