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