Files
Agent-System/internal/server/document_revision_handlers.go
T
weichenandCursor 9f6401847d fix(docgov): release human lease after PATCH request
Request-scoped defer release after authorized acquire so success/409/projection failure cannot leave a 15m residual that blocks squad runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 18:42:44 +00:00

466 lines
17 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/freedoc"
)
func (s *Server) registerDocumentRevisionRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/documents/{doc_id}/revision-state", s.handleDocRevisionState)
mux.HandleFunc("GET /api/v1/documents/{doc_id}/versions", s.handleDocRevisionListVersions)
mux.HandleFunc("GET /api/v1/documents/{doc_id}/versions/{version_no}", s.handleDocRevisionGetVersion)
mux.HandleFunc("GET /api/v1/documents/{doc_id}/diff", s.handleDocRevisionDiff)
mux.HandleFunc("GET /api/v1/documents/{doc_id}/manual-overlay", s.handleDocRevisionManualOverlay)
mux.HandleFunc("PATCH /api/v1/documents/{doc_id}/content", s.handleDocRevisionPatchContent)
mux.HandleFunc("GET /api/v1/documents/{doc_id}/agent-candidates/{candidate_id}", s.handleDocRevisionGetCandidate)
mux.HandleFunc("POST /api/v1/documents/{doc_id}/agent-candidates/{candidate_id}/decision", s.handleDocRevisionCandidateDecision)
mux.HandleFunc("GET /api/v1/documents/{doc_id}/change-reports/{report_id}", s.handleDocRevisionGetReport)
mux.HandleFunc("PATCH /api/v1/notifications/{id}", s.handlePatchNotification)
}
func (s *Server) docRev() *docrevision.Service {
if s == nil || s.svc == nil {
return nil
}
return s.svc.DocRevision()
}
func (s *Server) handleDocRevisionState(w http.ResponseWriter, r *http.Request) {
user, docID, ref, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
if rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
st, err := s.bootstrapRevisionIfNeeded(r.Context(), user, docID, ref)
if err != nil {
writeDocRevisionError(w, err)
return
}
if st != nil && st.PendingCandidateID != nil {
// Read path already authorized; decide capability requires write auth (author duty).
st.CanDecidePending = s.authorizeDocRevision(r.Context(), user, ref, true) == nil
}
writeJSONResponse(w, http.StatusOK, st)
}
func (s *Server) handleDocRevisionListVersions(w http.ResponseWriter, r *http.Request) {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
if rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
items, err := rev.ListVersions(r.Context(), docID)
if err != nil {
writeDocRevisionError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"versions": items})
}
func (s *Server) handleDocRevisionGetVersion(w http.ResponseWriter, r *http.Request) {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
if rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
vno, _ := strconv.Atoi(r.PathValue("version_no"))
view := r.URL.Query().Get("view")
if view == "" {
view = "final"
}
md, sha, err := rev.GetVersionContent(r.Context(), docID, vno, view)
if err != nil {
writeDocRevisionError(w, err)
return
}
w.Header().Set("ETag", `"`+sha+`"`)
writeJSONResponse(w, http.StatusOK, map[string]any{
"version_no": vno, "view": view, "content_sha256": sha, "content_markdown": md,
})
}
func (s *Server) handleDocRevisionDiff(w http.ResponseWriter, r *http.Request) {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
if rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
fromNo, _ := strconv.Atoi(r.URL.Query().Get("from_version"))
toNo, _ := strconv.Atoi(r.URL.Query().Get("to_version"))
fromView := r.URL.Query().Get("from_view")
toView := r.URL.Query().Get("to_view")
if fromView == "" {
fromView = "final"
}
if toView == "" {
toView = "final"
}
diff, err := rev.DiffVersions(r.Context(), docID, fromNo, toNo, fromView, toView)
if err != nil {
writeDocRevisionError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, diff)
}
func (s *Server) handleDocRevisionManualOverlay(w http.ResponseWriter, r *http.Request) {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
if rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
diff, err := rev.ManualOverlay(r.Context(), docID)
if err != nil {
writeDocRevisionError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, diff)
}
func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Request) {
user, docID, ref, ok := s.requireDocRevisionAccess(w, r, true)
if !ok {
return
}
rev := s.docRev()
if rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
var body struct {
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"})
return
}
idemp := r.Header.Get("Idempotency-Key")
if idemp == "" {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "Idempotency-Key required"})
return
}
var expectedHead *int64
var expectedRev *int
if match := strings.Trim(r.Header.Get("If-Match"), `"`); match != "" {
parts := strings.Split(match, ":")
if len(parts) == 2 {
if sid, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
expectedHead = &sid
}
if rn, err := strconv.Atoi(parts[1]); err == nil {
expectedRev = &rn
}
}
}
lifecycle := "drafting"
workDir := ""
projectID := int64(0)
kind := "controlled"
if ref != nil {
lifecycle = ref.LifecycleState
workDir = ref.WorkDir
projectID = ref.ProjectID
kind = ref.Kind
}
if kind == "orphan" {
writeDocRevisionError(w, docrevision.ErrNotFound)
return
}
// 1) Controlled: author duty + lease WITHOUT writing body (fail-closed before ledger).
// This UI has no edit-session heartbeat: release the request-scoped human lease on every exit
// (success, 409, projection failure) so a 15m residual cannot block a later squad run.
if kind == "controlled" && s.svc != nil && s.svc.DocGov() != nil {
if err := s.svc.DocGov().AssertHumanCanEdit(r.Context(), docID, user.ID, docgov.RoleAuthor); err != nil {
writeDocGovError(w, err)
return
}
holderID := fmt.Sprintf("%d", user.ID)
if user.ID <= 0 {
holderID = "human_anon"
}
defer func() {
_, _ = s.svc.DocGov().ReleaseLease(context.Background(), docID, string(docgov.LeaseHolderHuman), holderID)
}()
}
// 2) Bootstrap if needed, then commit ledger. No fact-source mutation yet.
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
}
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"},
LifecycleState: lifecycle, WorkDir: workDir, ProjectID: projectID, AllowCreateBaseline: false,
ClientBlockMap: body.ClientBlockMap,
})
if errors.Is(err, docrevision.ErrHeadConflict) && res != nil && res.Conflict != nil {
writeJSONResponse(w, http.StatusConflict, map[string]any{
"code": docrevision.ErrCodeHeadConflict, "conflict": res.Conflict,
})
return
}
if err != nil {
writeDocRevisionError(w, err)
return
}
// 3) Project fact-source AFTER ledger commit. Controlled docs use SaveDocumentContent so
// author_last_edited / audit trail stay consistent; failure → DOCUMENT_PROJECTION_FAILED
// (ledger remains authoritative; same Idempotency-Key retry re-projects).
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 {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "账本已提交但 DocGov 正文/审计投影失败;请用同一 Idempotency-Key 重试保存。账本 head 仍为权威。",
"state": res.State,
})
return
}
} else if workDir != "" {
if err := rev.ProjectOutputMarkdown(docID, s.dataRoot()); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "账本已提交但工作区投影失败;请重试保存。账本 head 仍为权威。",
"state": res.State,
})
return
}
}
if kind == "freeform" && s.svc != nil && s.svc.FreeformDoc() != nil {
md := body.Markdown
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.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "账本已提交但自由文档正文投影失败;请重试保存。账本 head 仍为权威。",
"state": res.State,
})
return
}
}
writeJSONResponse(w, http.StatusOK, res)
}
func (s *Server) handleDocRevisionGetCandidate(w http.ResponseWriter, r *http.Request) {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
if rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
cid, _ := strconv.ParseInt(r.PathValue("candidate_id"), 10, 64)
c, err := rev.GetCandidate(r.Context(), docID, cid)
if err != nil {
writeDocRevisionError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, c)
}
func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *http.Request) {
user, docID, _, ok := s.requireDocRevisionAccess(w, r, true)
if !ok {
return
}
rev := s.docRev()
if rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
cid, _ := strconv.ParseInt(r.PathValue("candidate_id"), 10, 64)
var body struct {
Decision string `json:"decision"`
Confirmed bool `json:"confirmed"`
ExpectedInputSHA string `json:"expected_input_sha"`
ExpectedOutputSHA string `json:"expected_output_sha"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid json"})
return
}
cand, _ := rev.GetCandidate(r.Context(), docID, cid)
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,
ActorSnapshot: docrevision.ActorSnapshot{DisplayName: user.Name},
})
if err != nil {
writeDocRevisionError(w, err)
return
}
// Always project ledger head to fact source after decision:
// commit → new head; reject → keep/restore previous head (never leave squad draft on disk).
if err := rev.ProjectOutputMarkdown(docID, s.dataRoot()); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "候选决策已写入账本,但工作区 output.md 投影失败;请重试决策或人工修复。",
"state": st,
})
return
}
if s.svc != nil && s.svc.DocGov() != nil && st != nil {
if err := s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, st.HeadSHA256, false); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "候选决策已写入账本,但 document_records 哈希投影失败;请重试。",
"state": st,
})
return
}
if body.Decision == "commit" && cand != nil && strings.TrimSpace(cand.ProposalID) != "" {
if err := s.svc.DocGov().MarkProposalApplied(r.Context(), cand.ProposalID); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_GOVERNANCE_FAILED",
"error": err.Error(),
"hint": "候选账本已提交且事实源已投影,但变更提案标记失败。请用同一 decision(相同 expected_*_sha)重试:已 committed 候选幂等,仅补 MarkProposalApplied;勿换候选或改正文。",
"state": st,
})
return
}
}
}
writeJSONResponse(w, http.StatusOK, st)
}
func (s *Server) handleDocRevisionGetReport(w http.ResponseWriter, r *http.Request) {
_, docID, _, ok := s.requireDocRevisionAccess(w, r, false)
if !ok {
return
}
rev := s.docRev()
if rev == nil {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
rid, _ := strconv.ParseInt(r.PathValue("report_id"), 10, 64)
rep, err := rev.GetChangeReport(r.Context(), docID, rid)
if err != nil {
writeDocRevisionError(w, err)
return
}
writeJSONResponse(w, http.StatusOK, rep)
}
func (s *Server) handlePatchNotification(w http.ResponseWriter, r *http.Request) {
user, ok := s.requirePrincipal(w, r)
if !ok {
return
}
if s.notifySvc == nil || !s.notifySvc.Available() {
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "notifications unavailable"})
return
}
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
var body struct {
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid json"})
return
}
if err := s.notifySvc.UpdateNotificationStatus(r.Context(), user.ID, id, body.Status); err != nil {
writeJSONResponse(w, http.StatusNotFound, map[string]any{"error": err.Error()})
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"ok": true})
}
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):
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):
writeJSONResponse(w, http.StatusLocked, map[string]any{"code": docrevision.ErrCodeLifecycleLocked, "error": err.Error()})
case errors.Is(err, docrevision.ErrCandidateStale):
writeJSONResponse(w, http.StatusConflict, map[string]any{"code": docrevision.ErrCodeCandidateStale, "error": err.Error()})
case errors.Is(err, docrevision.ErrNeedsConfirmation):
writeJSONResponse(w, http.StatusConflict, map[string]any{"code": docrevision.ErrCodeNeedsConfirmation, "error": err.Error()})
case errors.Is(err, docrevision.ErrReportInvalid):
writeJSONResponse(w, http.StatusUnprocessableEntity, map[string]any{"code": docrevision.ErrCodeReportInvalid, "error": err.Error()})
case errors.Is(err, docrevision.ErrTooLarge):
writeJSONResponse(w, http.StatusRequestEntityTooLarge, map[string]any{"code": docrevision.ErrCodeTooLarge, "error": err.Error()})
case errors.Is(err, docrevision.ErrForbidden):
writeJSONResponse(w, http.StatusForbidden, map[string]any{"code": docrevision.ErrCodeEditForbidden, "error": err.Error()})
default:
writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
}
}