fix(docgov): defer fact-source update until high-risk commit
Materialize needs_confirmation restores ledger head to output.md and document_records hash; only committed heads update DocGov. Decision handler projects after commit or reject. Stage gates DocGov proposals via PendingProposal, not bare RequiresAuthorApproval. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+115
-15
@@ -733,27 +733,23 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
|
||||
if res.Success && s.docGov != nil && strings.TrimSpace(docID) != "" && docRec != nil {
|
||||
_ = s.docGov.SetDocumentWorkDir(ctx, docID, res.WorkDir)
|
||||
sha := sha256.Sum256([]byte(res.MarkdownBody))
|
||||
shaStr := hex.EncodeToString(sha[:])
|
||||
_ = s.docGov.MarkDocumentContentUpdated(ctx, docID, shaStr, false)
|
||||
if appliedProposalID != "" {
|
||||
_ = s.docGov.MarkProposalApplied(ctx, appliedProposalID)
|
||||
dataRoot := "."
|
||||
if s.cfg != nil && s.cfg.DataDir != "" {
|
||||
dataRoot = s.cfg.DataDir
|
||||
}
|
||||
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
|
||||
docRec = latest
|
||||
}
|
||||
// DOCREV: first generation uses empty prior→agent output so a deterministic change report
|
||||
// is always visible; subsequent runs candidate against current head. Never static fiction.
|
||||
// Materialize failure must not be swallowed as task success.
|
||||
|
||||
// DOCREV: materialize first; only committed heads may update DocGov hash / proposal applied.
|
||||
// Squad may have already written candidate body to workDir/output.md — pending must restore ledger head.
|
||||
if s.docRev != nil && docRec != nil && strings.TrimSpace(docRec.DocID) != "" {
|
||||
actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed}
|
||||
if _, matErr := s.docRev.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
|
||||
mat, matErr := s.docRev.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
|
||||
DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey),
|
||||
ProposalID: appliedProposalID, OutputMarkdown: res.MarkdownBody,
|
||||
ProjectID: req.ProjectID, WorkDir: res.WorkDir,
|
||||
ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: actor,
|
||||
AutoCommitUserID: req.UserID,
|
||||
}); matErr != nil {
|
||||
})
|
||||
if matErr != nil {
|
||||
reason := fmt.Sprintf("文档版本账本/变更报告落盘失败: %v", matErr)
|
||||
return &TaskRunResult{
|
||||
TaskKey: res.TaskKey,
|
||||
@@ -772,6 +768,104 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
DocRecord: docRec,
|
||||
}, matErr
|
||||
}
|
||||
|
||||
if mat != nil && mat.Candidate != nil && mat.Candidate.Status == docrevision.CandidateNeedsConfirmation {
|
||||
if err := s.docRev.ProjectOutputMarkdown(docID, dataRoot); err != nil {
|
||||
reason := fmt.Sprintf("高风险待确认:恢复账本 head 到事实源失败: %v", err)
|
||||
return &TaskRunResult{
|
||||
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
|
||||
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
|
||||
Success: false, ErrorReason: reason, DocRecord: docRec,
|
||||
}, err
|
||||
}
|
||||
headSHA := ""
|
||||
if mat.State != nil {
|
||||
headSHA = mat.State.HeadSHA256
|
||||
}
|
||||
if headSHA != "" {
|
||||
if err := s.docGov.MarkDocumentContentUpdated(ctx, docID, headSHA, false); err != nil {
|
||||
reason := fmt.Sprintf("高风险待确认:恢复 document_records 哈希失败: %v", err)
|
||||
return &TaskRunResult{
|
||||
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
|
||||
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
|
||||
Success: false, ErrorReason: reason, DocRecord: docRec,
|
||||
}, err
|
||||
}
|
||||
}
|
||||
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
|
||||
docRec = latest
|
||||
}
|
||||
risk := ""
|
||||
if mat.Report != nil {
|
||||
risk = mat.Report.RiskLevel
|
||||
}
|
||||
var pendCand, pendRep int64
|
||||
if mat.Candidate != nil {
|
||||
pendCand = mat.Candidate.ID
|
||||
if mat.Candidate.ChangeReportID != nil {
|
||||
pendRep = *mat.Candidate.ChangeReportID
|
||||
}
|
||||
}
|
||||
note := fmt.Sprintf(
|
||||
"高风险变更(%s)待编制人确认:候选 #%d。正式正文仍为当前账本 head,候选正文仅保留在不可变快照中;请打开「待确认报告」确认提交或拒绝。",
|
||||
risk, pendCand,
|
||||
)
|
||||
return &TaskRunResult{
|
||||
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
|
||||
ExtractedData: res.ExtractedData, Attachments: res.Attachments, WorkDir: res.WorkDir,
|
||||
BackendUsed: res.BackendUsed, Duration: res.Duration, Success: true,
|
||||
QualityReport: res.QualityReport, Manifest: res.Manifest, AdvisorBrief: res.AdvisorBrief,
|
||||
DocRecord: docRec,
|
||||
// RequiresAuthorApproval signals "needs human decision"; distinguish from DocGov
|
||||
// proposal intercept via PendingAgentCandidateID (no PendingProposal).
|
||||
RequiresAuthorApproval: true,
|
||||
PendingAgentCandidateID: pendCand, PendingChangeReportID: pendRep,
|
||||
PendingRiskLevel: risk, PendingConfirmationNote: note,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Committed (auto-commit or ready path): project head then update DocGov.
|
||||
if err := s.docRev.ProjectOutputMarkdown(docID, dataRoot); err != nil {
|
||||
reason := fmt.Sprintf("账本已提交但工作区投影失败: %v", err)
|
||||
return &TaskRunResult{
|
||||
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
|
||||
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
|
||||
Success: false, ErrorReason: reason, DocRecord: docRec,
|
||||
}, err
|
||||
}
|
||||
headSHA := ""
|
||||
if mat != nil && mat.State != nil {
|
||||
headSHA = mat.State.HeadSHA256
|
||||
}
|
||||
if headSHA == "" {
|
||||
sha := sha256.Sum256([]byte(res.MarkdownBody))
|
||||
headSHA = hex.EncodeToString(sha[:])
|
||||
}
|
||||
if err := s.docGov.MarkDocumentContentUpdated(ctx, docID, headSHA, false); err != nil {
|
||||
reason := fmt.Sprintf("账本已提交但 DocGov 哈希投影失败: %v", err)
|
||||
return &TaskRunResult{
|
||||
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
|
||||
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
|
||||
Success: false, ErrorReason: reason, DocRecord: docRec,
|
||||
}, err
|
||||
}
|
||||
if appliedProposalID != "" {
|
||||
_ = s.docGov.MarkProposalApplied(ctx, appliedProposalID)
|
||||
}
|
||||
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
|
||||
docRec = latest
|
||||
}
|
||||
} else {
|
||||
// No docrevision: preserve prior best-effort hash update (CLI/legacy).
|
||||
sha := sha256.Sum256([]byte(res.MarkdownBody))
|
||||
shaStr := hex.EncodeToString(sha[:])
|
||||
_ = s.docGov.MarkDocumentContentUpdated(ctx, docID, shaStr, false)
|
||||
if appliedProposalID != "" {
|
||||
_ = s.docGov.MarkProposalApplied(ctx, appliedProposalID)
|
||||
}
|
||||
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
|
||||
docRec = latest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,9 +880,14 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
IsStale: false,
|
||||
})
|
||||
}
|
||||
if res.Success && req.TaskKey == "system_reqs" && s.projectWorkflow != nil && req.ProjectID > 0 {
|
||||
// Stale propagation only after fact source committed (not while pending confirmation).
|
||||
if res.Success && req.TaskKey == "system_reqs" && s.projectWorkflow != nil && req.ProjectID > 0 &&
|
||||
(s.docRev == nil || (docRec != nil && docRec.ContentSHA256 != "")) {
|
||||
sha := sha256.Sum256([]byte(res.MarkdownBody))
|
||||
shaStr := hex.EncodeToString(sha[:])
|
||||
if docRec != nil && docRec.ContentSHA256 != "" {
|
||||
shaStr = docRec.ContentSHA256
|
||||
}
|
||||
_, _ = s.projectWorkflow.CheckAndPropagateStale(ctx, docID, shaStr, "上游系统需求规格书已更新,下游验证方案需复核")
|
||||
}
|
||||
|
||||
@@ -939,7 +1038,8 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest)
|
||||
}, err
|
||||
}
|
||||
|
||||
if res != nil && res.RequiresAuthorApproval {
|
||||
// DocGov claimed-doc proposal intercept only — not high-risk revision pending confirmation.
|
||||
if res != nil && res.IsProposalIntercept() {
|
||||
return &StageRunResult{
|
||||
Stage: req.Stage,
|
||||
StageTitle: targetMeta.Title,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package bizdev
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/squad"
|
||||
)
|
||||
|
||||
// Terminal task statuses projected to chat / workflow_runs.
|
||||
const (
|
||||
TaskStatusCompleted = "completed"
|
||||
TaskStatusFailed = "failed"
|
||||
TaskStatusQualityFailed = "quality_failed"
|
||||
TaskStatusPendingAuthorConfirmation = "pending_author_confirmation"
|
||||
)
|
||||
|
||||
// AwaitsAgentConfirmation reports high/critical revision candidate awaiting author decision.
|
||||
// Distinct from DocGov change-proposal intercept (PendingProposal).
|
||||
func (r *TaskRunResult) AwaitsAgentConfirmation() bool {
|
||||
return r != nil && r.PendingAgentCandidateID > 0
|
||||
}
|
||||
|
||||
// IsProposalIntercept reports claimed-document DocGov change-proposal interception.
|
||||
func (r *TaskRunResult) IsProposalIntercept() bool {
|
||||
return r != nil && r.PendingProposal != nil && r.RequiresAuthorApproval
|
||||
}
|
||||
|
||||
// TerminalStatus maps TaskRunResult to a conversation/workflow terminal status.
|
||||
// Pending agent confirmation is never "completed" until commit+projection.
|
||||
func (r *TaskRunResult) TerminalStatus() string {
|
||||
if r == nil || !r.Success {
|
||||
return TaskStatusFailed
|
||||
}
|
||||
if r.AwaitsAgentConfirmation() {
|
||||
return TaskStatusPendingAuthorConfirmation
|
||||
}
|
||||
if r.QualityReport != nil && !r.QualityReport.Passed {
|
||||
return TaskStatusQualityFailed
|
||||
}
|
||||
return TaskStatusCompleted
|
||||
}
|
||||
|
||||
// CompletionSummary builds the assistant-visible completion text for sync and async finalizers.
|
||||
func (r *TaskRunResult) CompletionSummary() string {
|
||||
if r == nil {
|
||||
return "任务执行失败。"
|
||||
}
|
||||
title := r.ArtifactTitle
|
||||
if title == "" && r.TaskKey != "" {
|
||||
title = squad.TaskArtifactTitle(r.TaskKey)
|
||||
}
|
||||
if title == "" {
|
||||
title = "交付文档"
|
||||
}
|
||||
|
||||
status := r.TerminalStatus()
|
||||
var b strings.Builder
|
||||
|
||||
switch status {
|
||||
case TaskStatusPendingAuthorConfirmation:
|
||||
fmt.Fprintf(&b, "《%s》文档候选已生成,待编制人确认。\n\n", title)
|
||||
b.WriteString("当前状态:**待编制人确认**")
|
||||
if r.PendingRiskLevel != "" {
|
||||
fmt.Fprintf(&b, "(风险:%s)", r.PendingRiskLevel)
|
||||
}
|
||||
b.WriteString("\n正式正文仍为账本当前 head;候选正文仅保留在不可变快照中。请打开文档「待确认报告」确认提交或拒绝后,正式正文才会更新。")
|
||||
if r.PendingConfirmationNote != "" {
|
||||
fmt.Fprintf(&b, "\n%s", r.PendingConfirmationNote)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n候选 ID:%d", r.PendingAgentCandidateID)
|
||||
if r.PendingChangeReportID > 0 {
|
||||
fmt.Fprintf(&b, " · 变更报告:%d", r.PendingChangeReportID)
|
||||
}
|
||||
case TaskStatusQualityFailed:
|
||||
fmt.Fprintf(&b, "《%s》已生成。\n\n", title)
|
||||
b.WriteString("当前状态:**质量检查未通过,待整改**")
|
||||
if r.QualityReport != nil {
|
||||
fmt.Fprintf(&b, "\n质量评分:%.0f/100", r.QualityReport.Score)
|
||||
if len(r.QualityReport.Violations) > 0 {
|
||||
fmt.Fprintf(&b, "\n主要问题:%s", strings.Join(r.QualityReport.Violations, ";"))
|
||||
}
|
||||
}
|
||||
case TaskStatusCompleted:
|
||||
fmt.Fprintf(&b, "《%s》已生成。\n\n", title)
|
||||
b.WriteString("当前状态:**已完成**")
|
||||
if r.QualityReport != nil && r.QualityReport.Passed {
|
||||
fmt.Fprintf(&b, "\n质量评分:%.0f/100", r.QualityReport.Score)
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(&b, "《%s》未成功落盘。\n\n", title)
|
||||
if r.ErrorReason != "" {
|
||||
fmt.Fprintf(&b, "执行未成功:%s", r.ErrorReason)
|
||||
} else {
|
||||
b.WriteString("任务执行失败。")
|
||||
}
|
||||
}
|
||||
|
||||
if r.DocRecord != nil && r.DocRecord.DocID != "" {
|
||||
fmt.Fprintf(&b, "\n\n📄 受控文档:`%s`", r.DocRecord.DocID)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ArtifactMetadata returns chat metadata fields for a clickable document entry.
|
||||
func (r *TaskRunResult) ArtifactMetadata() map[string]any {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
docID := ""
|
||||
if r.DocRecord != nil {
|
||||
docID = r.DocRecord.DocID
|
||||
}
|
||||
if docID == "" {
|
||||
return nil
|
||||
}
|
||||
title := r.ArtifactTitle
|
||||
if title == "" && r.TaskKey != "" {
|
||||
title = squad.TaskArtifactTitle(r.TaskKey)
|
||||
}
|
||||
out := map[string]any{
|
||||
"doc_id": docID,
|
||||
"title": title,
|
||||
"task_key": r.TaskKey,
|
||||
}
|
||||
if r.WorkDir != "" {
|
||||
out["workspace_id"] = r.WorkDir
|
||||
}
|
||||
if r.DocRecord != nil && r.DocRecord.ContentSHA256 != "" {
|
||||
out["sha256"] = r.DocRecord.ContentSHA256
|
||||
}
|
||||
if r.QualityReport != nil {
|
||||
out["quality_passed"] = r.QualityReport.Passed
|
||||
out["quality_score"] = r.QualityReport.Score
|
||||
if len(r.QualityReport.Violations) > 0 {
|
||||
out["quality_violations"] = r.QualityReport.Violations
|
||||
}
|
||||
}
|
||||
if r.AwaitsAgentConfirmation() {
|
||||
out["pending_agent_candidate_id"] = r.PendingAgentCandidateID
|
||||
if r.PendingChangeReportID > 0 {
|
||||
out["pending_change_report_id"] = r.PendingChangeReportID
|
||||
}
|
||||
if r.PendingRiskLevel != "" {
|
||||
out["pending_risk_level"] = r.PendingRiskLevel
|
||||
}
|
||||
out["awaiting_author_confirmation"] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
+38
-30
@@ -41,20 +41,20 @@ type Attachment = squad.Attachment
|
||||
type CaseSearchOptions = ctxmgr.CaseSearchOptions
|
||||
|
||||
type TaskRunRequest struct {
|
||||
TaskKey string `json:"task_key"`
|
||||
Title string `json:"title,omitempty"` // 自由任务人类可读标题(非标任务自主规划使用)
|
||||
ProjectName string `json:"project_name"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
DeviceType string `json:"device_type"`
|
||||
IntendedUse string `json:"intended_use"`
|
||||
TargetMarket string `json:"target_market"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
Materials []MaterialInput `json:"materials"`
|
||||
Backend string `json:"backend"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
WorkDir string `json:"work_dir"`
|
||||
CustomSteps []squad.StepSpec `json:"custom_steps,omitempty"`
|
||||
AutoRepair bool `json:"auto_repair,omitempty"`
|
||||
TaskKey string `json:"task_key"`
|
||||
Title string `json:"title,omitempty"` // 自由任务人类可读标题(非标任务自主规划使用)
|
||||
ProjectName string `json:"project_name"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
DeviceType string `json:"device_type"`
|
||||
IntendedUse string `json:"intended_use"`
|
||||
TargetMarket string `json:"target_market"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
Materials []MaterialInput `json:"materials"`
|
||||
Backend string `json:"backend"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
WorkDir string `json:"work_dir"`
|
||||
CustomSteps []squad.StepSpec `json:"custom_steps,omitempty"`
|
||||
AutoRepair bool `json:"auto_repair,omitempty"`
|
||||
KnowledgeHits []squad.KnowledgeHit `json:"knowledge_hits,omitempty"`
|
||||
PriorDocs []squad.PriorDocItem `json:"prior_docs,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
@@ -75,22 +75,30 @@ type TaskRunRequest struct {
|
||||
}
|
||||
|
||||
type TaskRunResult struct {
|
||||
TaskKey string `json:"task_key"`
|
||||
ArtifactTitle string `json:"artifact_title"`
|
||||
MarkdownBody string `json:"markdown_body"`
|
||||
ExtractedData map[string]string `json:"extracted_data"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
WorkDir string `json:"work_dir"`
|
||||
BackendUsed string `json:"backend_used"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Success bool `json:"success"`
|
||||
ErrorReason string `json:"error_reason,omitempty"`
|
||||
QualityReport *squad.QualityReport `json:"quality_report,omitempty"`
|
||||
Manifest *squad.Manifest `json:"manifest,omitempty"`
|
||||
AdvisorBrief *AdvisorBriefing `json:"advisor_brief,omitempty"`
|
||||
DocRecord *docgov.DocumentRecord `json:"doc_record,omitempty"`
|
||||
PendingProposal *docgov.ChangeProposal `json:"pending_proposal,omitempty"`
|
||||
RequiresAuthorApproval bool `json:"requires_author_approval,omitempty"`
|
||||
TaskKey string `json:"task_key"`
|
||||
ArtifactTitle string `json:"artifact_title"`
|
||||
MarkdownBody string `json:"markdown_body"`
|
||||
ExtractedData map[string]string `json:"extracted_data"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
WorkDir string `json:"work_dir"`
|
||||
BackendUsed string `json:"backend_used"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Success bool `json:"success"`
|
||||
ErrorReason string `json:"error_reason,omitempty"`
|
||||
QualityReport *squad.QualityReport `json:"quality_report,omitempty"`
|
||||
Manifest *squad.Manifest `json:"manifest,omitempty"`
|
||||
AdvisorBrief *AdvisorBriefing `json:"advisor_brief,omitempty"`
|
||||
DocRecord *docgov.DocumentRecord `json:"doc_record,omitempty"`
|
||||
PendingProposal *docgov.ChangeProposal `json:"pending_proposal,omitempty"`
|
||||
// RequiresAuthorApproval: human must decide. Disambiguate:
|
||||
// - PendingProposal != nil → DocGov claimed-doc change proposal
|
||||
// - PendingAgentCandidateID > 0 → high-risk revision candidate (ledger not committed)
|
||||
RequiresAuthorApproval bool `json:"requires_author_approval,omitempty"`
|
||||
// Pending agent revision confirmation (high/critical): fact source stays on ledger head.
|
||||
PendingAgentCandidateID int64 `json:"pending_agent_candidate_id,omitempty"`
|
||||
PendingChangeReportID int64 `json:"pending_change_report_id,omitempty"`
|
||||
PendingRiskLevel string `json:"pending_risk_level,omitempty"`
|
||||
PendingConfirmationNote string `json:"pending_confirmation_note,omitempty"`
|
||||
}
|
||||
|
||||
// StageRunRequest 运行指定成熟度阶段请求
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package docrevision_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
)
|
||||
|
||||
func TestPendingHighRisk_DoesNotAdvanceFactSource_UntilConfirm(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dataDir := t.TempDir()
|
||||
workDir := filepath.Join(dataDir, "ws-pending")
|
||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docID := "doc_fact_pending"
|
||||
oldMD := "# base\n\n法规引用 GB9706\n"
|
||||
newMD := "# base\n\n" // deletes 法规 → high risk
|
||||
|
||||
// Simulate squad already writing unconfirmed body to fact source.
|
||||
if err := os.WriteFile(filepath.Join(workDir, "output.md"), []byte(newMD), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSHA := sha256.Sum256([]byte(docrevision.CanonicalizeMarkdown(oldMD)))
|
||||
oldSHAStr := hex.EncodeToString(oldSHA[:])
|
||||
newSHA := sha256.Sum256([]byte(docrevision.CanonicalizeMarkdown(newMD)))
|
||||
newSHAStr := hex.EncodeToString(newSHA[:])
|
||||
|
||||
eng := docgov.NewGovernanceEngine(nil)
|
||||
if _, err := eng.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "a", 2, "r", 3, "p"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = eng.MarkDocumentContentUpdated(ctx, docID, oldSHAStr, false)
|
||||
|
||||
rev := docrevision.NewService(nil)
|
||||
rev.DataDir = dataDir
|
||||
st, err := rev.OpenTemplateVersion(ctx, docID, oldMD, docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mat, err := rev.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
|
||||
DocID: docID, RunID: "run-pending-fact", OutputMarkdown: newMD,
|
||||
ProjectID: 1, WorkDir: workDir, ActorKind: docrevision.ActorAgentSquad,
|
||||
AutoCommitUserID: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("materialize: %v", err)
|
||||
}
|
||||
if mat.Candidate == nil || mat.Candidate.Status != docrevision.CandidateNeedsConfirmation {
|
||||
t.Fatalf("want needs_confirmation, got %+v", mat.Candidate)
|
||||
}
|
||||
|
||||
// Restore fact source from ledger head (bizdev contract).
|
||||
if err := rev.ProjectOutputMarkdown(docID, dataDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := eng.MarkDocumentContentUpdated(ctx, docID, st.HeadSHA256, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gotMD, err := os.ReadFile(filepath.Join(workDir, "output.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if docrevision.CanonicalizeMarkdown(string(gotMD)) != docrevision.CanonicalizeMarkdown(oldMD) {
|
||||
t.Fatalf("pending must restore output.md to ledger head; got %q want old", string(gotMD))
|
||||
}
|
||||
doc, err := eng.GetDocument(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.ContentSHA256 != st.HeadSHA256 {
|
||||
t.Fatalf("document_records hash must stay on head while pending: got %s want %s", doc.ContentSHA256, st.HeadSHA256)
|
||||
}
|
||||
if doc.ContentSHA256 == newSHAStr {
|
||||
t.Fatal("document_records must not carry unconfirmed candidate hash")
|
||||
}
|
||||
|
||||
// Confirm → project new head.
|
||||
st2, err := rev.CommitAgentVersion(ctx, docID, mat.Candidate.ID, docrevision.CommitAgentDecision{
|
||||
Decision: "commit", Confirmed: true, ActorUserID: 1, ExpectedInputSHA: st.HeadSHA256,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rev.ProjectOutputMarkdown(docID, dataDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := eng.MarkDocumentContentUpdated(ctx, docID, st2.HeadSHA256, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotAfter, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
|
||||
if docrevision.CanonicalizeMarkdown(string(gotAfter)) != docrevision.CanonicalizeMarkdown(newMD) {
|
||||
t.Fatalf("after confirm output.md must be candidate body, got %q", string(gotAfter))
|
||||
}
|
||||
doc2, _ := eng.GetDocument(ctx, docID)
|
||||
if doc2.ContentSHA256 != st2.HeadSHA256 {
|
||||
t.Fatalf("after confirm hash want %s got %s", st2.HeadSHA256, doc2.ContentSHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingHighRisk_RejectKeepsOldHead(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dataDir := t.TempDir()
|
||||
workDir := filepath.Join(dataDir, "ws-reject")
|
||||
_ = os.MkdirAll(workDir, 0o755)
|
||||
docID := "doc_fact_reject"
|
||||
oldMD := "# base\n\n法规引用 YY0505\n"
|
||||
newMD := "# base\n\n"
|
||||
|
||||
_ = os.WriteFile(filepath.Join(workDir, "output.md"), []byte(newMD), 0o644)
|
||||
eng := docgov.NewGovernanceEngine(nil)
|
||||
_, _ = eng.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "a", 2, "r", 3, "p")
|
||||
rev := docrevision.NewService(nil)
|
||||
rev.DataDir = dataDir
|
||||
st, err := rev.OpenTemplateVersion(ctx, docID, oldMD, docrevision.ActorSnapshot{}, 1, workDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mat, err := rev.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
|
||||
DocID: docID, RunID: "run-rej", OutputMarkdown: newMD, WorkDir: workDir,
|
||||
ActorKind: docrevision.ActorAgentSquad, AutoCommitUserID: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mat.Candidate.Status != docrevision.CandidateNeedsConfirmation {
|
||||
t.Fatalf("want needs_confirmation got %s", mat.Candidate.Status)
|
||||
}
|
||||
_ = rev.ProjectOutputMarkdown(docID, dataDir)
|
||||
_ = eng.MarkDocumentContentUpdated(ctx, docID, st.HeadSHA256, false)
|
||||
|
||||
st2, err := rev.CommitAgentVersion(ctx, docID, mat.Candidate.ID, docrevision.CommitAgentDecision{
|
||||
Decision: "reject", Confirmed: true, ActorUserID: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rev.ProjectOutputMarkdown(docID, dataDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = eng.MarkDocumentContentUpdated(ctx, docID, st2.HeadSHA256, false)
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
|
||||
if docrevision.CanonicalizeMarkdown(string(got)) != docrevision.CanonicalizeMarkdown(oldMD) {
|
||||
t.Fatalf("reject must keep old head on disk, got %q", string(got))
|
||||
}
|
||||
if st2.CurrentVersionNo != 1 {
|
||||
t.Fatalf("reject must not advance version, got V%d", st2.CurrentVersionNo)
|
||||
}
|
||||
if st2.PendingCandidateID != nil {
|
||||
t.Fatalf("pending must clear after reject")
|
||||
}
|
||||
}
|
||||
@@ -388,6 +388,16 @@ func (s *Service) MaterializeAgentOutput(ctx context.Context, req AgentMateriali
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.WorkDir) != "" {
|
||||
if err := s.SetDocWorkDir(ctx, req.DocID, req.WorkDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err = s.GetRevisionState(ctx, req.DocID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
cand, rep, err := s.CreateAgentCandidate(ctx, AgentCandidateRequest{
|
||||
DocID: req.DocID, RunID: req.RunID, ProposalID: req.ProposalID,
|
||||
InputSnapshotID: st.HeadSnapshotID, OutputMarkdown: req.OutputMarkdown, ReportJSON: req.ReportJSON,
|
||||
@@ -844,6 +854,27 @@ func (s *Service) ImportFreeformLegacy(ctx context.Context, docID string, ownerI
|
||||
return s.mustStateLocked(docID), nil
|
||||
}
|
||||
|
||||
// SetDocWorkDir updates the in-memory ledger root workDir used by ProjectOutputMarkdown.
|
||||
func (s *Service) SetDocWorkDir(ctx context.Context, docID, workDir string) error {
|
||||
if err := requireDocID(docID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(workDir) == "" {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.hydrateDocLocked(ctx, docID); err != nil {
|
||||
return err
|
||||
}
|
||||
root := s.roots[docID]
|
||||
if root == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
root.WorkDir = workDir
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProjectOutputMarkdown writes DB head to workDir/output.md (compatibility projection).
|
||||
func (s *Service) ProjectOutputMarkdown(docID, repoRoot string) error {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -331,6 +331,7 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht
|
||||
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,
|
||||
@@ -341,6 +342,32 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht
|
||||
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) != "" {
|
||||
_ = s.svc.DocGov().MarkProposalApplied(r.Context(), cand.ProposalID)
|
||||
}
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user