Activated projects can update duties, approval policy, and document enablement. Changing the workflow definition stays rejected, and project 143 remains frozen. Co-authored-by: Cursor <cursoragent@cursor.com>
213 lines
6.7 KiB
Go
213 lines
6.7 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/app/docflow"
|
|
"chat-agent-system/internal/docgov"
|
|
"chat-agent-system/internal/docquery"
|
|
"chat-agent-system/internal/docrevision"
|
|
)
|
|
|
|
func (s *Server) docflowQuery() *docflow.Query {
|
|
if s == nil || s.svc == nil {
|
|
return nil
|
|
}
|
|
return &docflow.Query{Docs: &docquery.Service{
|
|
Gov: s.gov,
|
|
Rev: s.docRev(),
|
|
RepoRoot: s.repoRoot(),
|
|
Authorize: func(ctx context.Context, p docquery.Principal, rec *docgov.DocumentRecord) error {
|
|
return s.authorizeControlledRead(ctx, p, rec)
|
|
},
|
|
FavDegradedRead: s.cfg != nil && s.cfg.FAVDegradedRead,
|
|
}}
|
|
}
|
|
|
|
func (s *Server) authorizeControlledRead(ctx context.Context, p docquery.Principal, rec *docgov.DocumentRecord) error {
|
|
if rec == nil || p.UserID == 0 {
|
|
return docquery.ErrClosed
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return docquery.ErrClosed
|
|
}
|
|
admin := false
|
|
if s.authzSvc != nil {
|
|
ok, _ := s.authzSvc.IsSystemAdmin(ctx, p.UserID, "")
|
|
admin = ok
|
|
}
|
|
if s.authorizer != nil {
|
|
if err := s.authorizer.RequireProjectAccessOrAdmin(ctx, rec.ProjectID, p.UserID, admin); err != nil {
|
|
return docquery.ErrClosed
|
|
}
|
|
}
|
|
if rec.TaskKey != "" && !s.allowWorkflowDocTaskAccess(ctx, rec.ProjectID, p.UserID, rec.TaskKey) {
|
|
return docquery.ErrClosed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) repoRoot() string {
|
|
if s.cfg != nil && s.cfg.DataDir != "" {
|
|
// DataDir 形如 ./data/dev;工作区路径形如 data/dev/workspaces/...,相对仓库根目录
|
|
return filepath.Clean(filepath.Join(s.cfg.DataDir, "..", ".."))
|
|
}
|
|
return "."
|
|
}
|
|
|
|
// handleListProjectWorkflowDocuments 列出项目工作流受控文档。
|
|
func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *http.Request) {
|
|
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || projectID <= 0 {
|
|
writeSafeError(w, r, http.StatusBadRequest, "invalid project id")
|
|
return
|
|
}
|
|
category := r.URL.Query().Get("category")
|
|
if category != "" && category != "workflow" {
|
|
writeSafeError(w, r, http.StatusBadRequest, "unsupported category")
|
|
return
|
|
}
|
|
|
|
user, ok := s.requireProjectAccess(w, r, projectID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if s.gov == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
|
|
return
|
|
}
|
|
|
|
if s.instSvc != nil {
|
|
if err := s.instSvc.MaterializeLiveDocs(r.Context(), projectID); err != nil {
|
|
slog.Warn("materialize live docs", "project_id", projectID, "err", err)
|
|
}
|
|
}
|
|
docs, err := s.gov.ListWorkflowDocumentsByProject(r.Context(), projectID)
|
|
if err != nil {
|
|
writeSafeError(w, r, http.StatusInternalServerError, "list project documents failed: "+err.Error())
|
|
return
|
|
}
|
|
docs = s.filterWorkflowDocsByStageAccess(r.Context(), projectID, user.ID, docs)
|
|
writeJSONResponse(w, http.StatusOK, docs)
|
|
}
|
|
|
|
// handleDocGovGetDocumentContent 读取受控文档 Markdown 正文。
|
|
func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.Request) {
|
|
docID := r.PathValue("doc_id")
|
|
if docID == "" {
|
|
writeSafeError(w, r, http.StatusBadRequest, "doc_id required")
|
|
return
|
|
}
|
|
|
|
user, ok := s.requireAuthenticatedUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
q := s.docflowQuery()
|
|
if q == nil || q.Docs == nil || q.Docs.Gov == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
|
|
return
|
|
}
|
|
view, err := q.Get(r.Context(), user.ID, docID)
|
|
if err != nil {
|
|
if errors.Is(err, docquery.ErrLedgerMissing) && view != nil {
|
|
writeJSONResponse(w, http.StatusOK, view)
|
|
return
|
|
}
|
|
if errors.Is(err, docquery.ErrClosed) {
|
|
writeJSONResponse(w, http.StatusNotFound, map[string]any{
|
|
"error_code": docgov.ErrCodeDocumentNotFound, "message": "document not found",
|
|
})
|
|
return
|
|
}
|
|
writeControlledContentError(w, r, err)
|
|
return
|
|
}
|
|
|
|
if inm := r.Header.Get("If-None-Match"); inm != "" && inm == view.ContentSHA256 {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
w.Header().Set("ETag", view.ContentSHA256)
|
|
writeJSONResponse(w, http.StatusOK, view)
|
|
}
|
|
|
|
func (s *Server) loadControlledDocumentContent(ctx context.Context, docID string) (*docgov.DocumentContent, error) {
|
|
if s == nil || s.gov == nil {
|
|
return nil, docgov.ErrDocumentNotFound
|
|
}
|
|
svc := &docquery.Service{Gov: s.gov, Rev: s.docRev(), RepoRoot: s.repoRoot()}
|
|
view, err := svc.Get(ctx, docquery.Principal{UserID: 1}, docquery.DocRef{DocID: docID})
|
|
if err != nil && !errors.Is(err, docquery.ErrLedgerMissing) {
|
|
return nil, err
|
|
}
|
|
if view != nil && view.Markdown != "" {
|
|
return view.ToDocumentContent(), nil
|
|
}
|
|
return s.gov.ReadDocumentContent(ctx, docID, s.repoRoot())
|
|
}
|
|
|
|
// serveLedgerHeadOrConflict keeps the historical helper name. Projection is a
|
|
// consistency cache; ledger head is served for missing/empty/drift.
|
|
func serveLedgerHeadOrConflict(docID string, version int, tmpl bool, headMD, headSHA, recordSHA string, class docrevision.ProjectionClass) (*docgov.DocumentContent, error) {
|
|
consistency := docquery.ConsistencyAligned
|
|
switch class.Class {
|
|
case "", docrevision.ProjectionAligned, docrevision.ProjectionNoLedger:
|
|
consistency = docquery.ConsistencyAligned
|
|
case docrevision.ProjectionMissing:
|
|
consistency = docquery.ConsistencyMissing
|
|
case docrevision.ProjectionEmpty:
|
|
consistency = docquery.ConsistencyEmpty
|
|
default:
|
|
consistency = docquery.ConsistencyDrift
|
|
}
|
|
return &docgov.DocumentContent{
|
|
DocID: docID, Version: version, ContentSHA256: headSHA, Markdown: headMD, IsTemplateOnly: tmpl,
|
|
Consistency: consistency, RecordSHA256: recordSHA, FileSHA256: class.RawSHA,
|
|
}, nil
|
|
}
|
|
|
|
func writeControlledContentError(w http.ResponseWriter, r *http.Request, err error) {
|
|
corr := r.Header.Get("X-Request-ID")
|
|
if corr == "" {
|
|
corr = strconv.FormatInt(time.Now().UnixNano(), 10)
|
|
}
|
|
var ce *docgov.ContentError
|
|
if errors.As(err, &ce) {
|
|
status := http.StatusConflict
|
|
if ce.Code == docgov.ErrCodeDocumentNotFound {
|
|
status = http.StatusNotFound
|
|
}
|
|
writeJSONResponse(w, status, map[string]any{
|
|
"error_code": ce.Code,
|
|
"message": ce.Error(),
|
|
"retryable": ce.Retryable,
|
|
"needs_admin": ce.NeedsAdmin,
|
|
"correlation_id": corr,
|
|
"record_sha256": ce.RecordSHA,
|
|
"file_sha256": ce.FileSHA,
|
|
"head_sha256": ce.HeadSHA,
|
|
"projection_class": ce.Class,
|
|
})
|
|
return
|
|
}
|
|
if errors.Is(err, docgov.ErrDocumentNotFound) {
|
|
writeJSONResponse(w, http.StatusNotFound, map[string]any{
|
|
"error_code": docgov.ErrCodeDocumentNotFound, "message": err.Error(),
|
|
"retryable": false, "needs_admin": false, "correlation_id": corr,
|
|
})
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusConflict, map[string]any{
|
|
"error_code": "DOCUMENT_CONTENT_READ_FAILED", "message": err.Error(),
|
|
"retryable": true, "needs_admin": false, "correlation_id": corr,
|
|
})
|
|
}
|