feat(api): add workflow document list, content read, and backfill script

Expose project controlled documents from DocGov, serve authenticated
output.md content by doc_id, and add idempotent backfill for run_1789111889557885000.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-11 16:46:53 +08:00
co-authored by Cursor
parent 6e489733ec
commit bcfb9f415b
4 changed files with 285 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
package docgov
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// WorkflowDocumentSummary 项目工作流受控文档列表项。
type WorkflowDocumentSummary struct {
DocID string `json:"doc_id"`
ProjectID int64 `json:"project_id"`
TaskKey string `json:"task_key"`
Title string `json:"title"`
LifecycleState DocumentState `json:"lifecycle_state"`
IsTemplateOnly bool `json:"is_template_only"`
ContentSHA256 string `json:"content_sha256"`
WorkDir string `json:"work_dir,omitempty"`
Version int `json:"version"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// DocumentContent 受控文档正文读取结果。
type DocumentContent struct {
DocID string `json:"doc_id"`
Version int `json:"version"`
ContentSHA256 string `json:"content_sha256"`
Markdown string `json:"markdown"`
IsTemplateOnly bool `json:"is_template_only"`
}
// ListWorkflowDocumentsByProject 列出项目下工作流受控文档(task_key 非空)。
func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, projectID int64) ([]WorkflowDocumentSummary, error) {
if projectID <= 0 {
return nil, fmt.Errorf("invalid project_id")
}
if e.pool != nil {
rows, err := e.pool.Query(ctx, `
SELECT doc_id, project_id, task_key, title, current_state, is_template_only,
COALESCE(content_sha256, ''), COALESCE(work_dir, ''), version, updated_at
FROM document_records
WHERE project_id = $1 AND task_key <> ''
ORDER BY task_key ASC, doc_id ASC
`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]WorkflowDocumentSummary, 0)
for rows.Next() {
var item WorkflowDocumentSummary
var state string
var updatedAt time.Time
if err := rows.Scan(
&item.DocID, &item.ProjectID, &item.TaskKey, &item.Title, &state,
&item.IsTemplateOnly, &item.ContentSHA256, &item.WorkDir, &item.Version, &updatedAt,
); err != nil {
return nil, err
}
item.LifecycleState = DocumentState(state)
if !updatedAt.IsZero() {
item.UpdatedAt = updatedAt.Format(time.RFC3339)
}
out = append(out, item)
}
return out, rows.Err()
}
e.mu.Lock()
defer e.mu.Unlock()
out := make([]WorkflowDocumentSummary, 0)
for _, doc := range e.memDocs {
if doc.ProjectID == projectID && doc.TaskKey != "" {
out = append(out, WorkflowDocumentSummary{
DocID: doc.DocID,
ProjectID: doc.ProjectID,
TaskKey: doc.TaskKey,
Title: doc.Title,
LifecycleState: doc.CurrentState,
IsTemplateOnly: doc.IsTemplateOnly,
ContentSHA256: doc.ContentSHA256,
WorkDir: doc.WorkDir,
Version: doc.Version,
})
}
}
return out, nil
}
// ReadDocumentContent 读取受控文档 output.md 正文并校验 SHA-256。
func (e *GovernanceEngine) ReadDocumentContent(ctx context.Context, docID string, repoRoot string) (*DocumentContent, error) {
doc, err := e.GetDocument(ctx, docID)
if err != nil {
return nil, err
}
if doc.WorkDir == "" {
return nil, fmt.Errorf("document has no work_dir")
}
workDir := doc.WorkDir
if !filepath.IsAbs(workDir) && repoRoot != "" {
workDir = filepath.Join(repoRoot, workDir)
}
path := filepath.Join(workDir, "output.md")
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read document content: %w", err)
}
if len(data) == 0 {
return nil, fmt.Errorf("document content is empty")
}
sum := sha256.Sum256(data)
shaStr := hex.EncodeToString(sum[:])
if doc.ContentSHA256 != "" && !strings.EqualFold(doc.ContentSHA256, shaStr) {
return nil, fmt.Errorf("content sha256 mismatch: record=%s file=%s", doc.ContentSHA256, shaStr)
}
return &DocumentContent{
DocID: doc.DocID,
Version: doc.Version,
ContentSHA256: shaStr,
Markdown: string(data),
IsTemplateOnly: doc.IsTemplateOnly,
}, nil
}
+68
View File
@@ -0,0 +1,68 @@
package server
import (
"net/http"
"path/filepath"
"strconv"
)
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 {
http.Error(w, "invalid project id", http.StatusBadRequest)
return
}
category := r.URL.Query().Get("category")
if category != "" && category != "workflow" {
http.Error(w, "unsupported category", http.StatusBadRequest)
return
}
if s.svc == nil || s.svc.DocGov() == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
return
}
docs, err := s.svc.DocGov().ListWorkflowDocumentsByProject(r.Context(), projectID)
if err != nil {
http.Error(w, "list project documents failed: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSONResponse(w, http.StatusOK, docs)
}
// handleDocGovGetDocumentContent 读取受控文档 Markdown 正文。
func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.Request) {
docID := r.PathValue("doc_id")
if docID == "" {
http.Error(w, "doc_id required", http.StatusBadRequest)
return
}
if s.svc == nil || s.svc.DocGov() == nil {
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
return
}
content, err := s.svc.DocGov().ReadDocumentContent(r.Context(), docID, s.repoRoot())
if err != nil {
http.Error(w, "read document content failed: "+err.Error(), http.StatusNotFound)
return
}
if inm := r.Header.Get("If-None-Match"); inm != "" && inm == content.ContentSHA256 {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", content.ContentSHA256)
writeJSONResponse(w, http.StatusOK, content)
}
+2
View File
@@ -102,6 +102,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /api/v1/projects/{id}/workflows/{workflow_key}/run", s.handleRunProjectWorkflow)
mux.HandleFunc("GET /api/v1/projects/{id}/workflows/{workflow_key}/runs", s.handleListProjectWorkflowRuns)
mux.HandleFunc("GET /api/v1/projects/{id}/lineage", s.handleGetProjectLineage)
mux.HandleFunc("GET /api/v1/projects/{id}/documents", s.handleListProjectWorkflowDocuments)
// 3. 对话协同服务 (标准 JSON 阻塞)
mux.HandleFunc("POST /api/v1/chat", s.handleChat)
@@ -162,6 +163,7 @@ func (s *Server) Routes() http.Handler {
// 8. 受控文档编制、审核、批准生命周期与认领治理
mux.HandleFunc("GET /api/v1/docgov/document", s.handleDocGovGetDocument)
mux.HandleFunc("GET /api/v1/docgov/documents/{doc_id}/content", s.handleDocGovGetDocumentContent)
mux.HandleFunc("POST /api/v1/docgov/claim", s.handleDocGovClaim)
mux.HandleFunc("POST /api/v1/docgov/submit-review", s.handleDocGovSubmitReview)
mux.HandleFunc("POST /api/v1/docgov/review-decision", s.handleDocGovReviewDecision)
+83
View File
@@ -0,0 +1,83 @@
// 一次性幂等回填:run_1789111889557885000 / 消息 25548 / doc_proj56_intake
//
// 用法(dev 环境,需 Postgres 可达):
// go run ./scripts/backfill_task_run_projection.go
//
//go:build ignore
package main
import (
"context"
"fmt"
"log"
"os"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/server"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/taskrun"
)
const (
runID = "run_1789111889557885000"
messageID = int64(25548)
docID = "doc_proj56_intake"
expectedSHA256 = "5329861dadd23ea48baff7079a98fa56e1a1e32a0cf5deda2ca291e57902b94c"
artifactTitle = "用户初始需求收集表"
workDir = "data/dev/workspaces/bizdev/intake_1789111889595658000"
)
func main() {
cfg, err := config.Load("")
if err != nil {
log.Fatal(err)
}
svc := bizdev.NewService(cfg)
srv := server.NewServer(cfg, svc)
ctx := context.Background()
pwSvc := svc.ProjectWorkflow()
if pwSvc == nil {
log.Fatal("project workflow service unavailable")
}
record := &taskrun.RunRecord{
RunID: runID,
SessionID: "sess-p56-u3",
ProjectID: 56,
TaskKey: "intake",
AssistantMsgID: messageID,
WorkspaceID: workDir,
}
result := &bizdev.TaskRunResult{
Success: true,
ArtifactTitle: artifactTitle,
WorkDir: workDir,
TaskKey: "intake",
QualityReport: &squad.QualityReport{
Passed: false,
Score: 40,
Violations: []string{"不做展开", "篇幅有限", "从略"},
},
DocRecord: &docgov.DocumentRecord{
DocID: docID,
ContentSHA256: expectedSHA256,
WorkDir: workDir,
},
}
status, finErr := srv.FinalizeTaskRunProjection(ctx, record, result)
if finErr != nil {
log.Fatalf("finalize failed: %v", finErr)
}
fmt.Printf("backfill complete: status=%s run_id=%s message_id=%d doc_id=%s sha=%s\n",
status, runID, messageID, docID, expectedSHA256)
os.Exit(0)
}