444 lines
15 KiB
Go
444 lines
15 KiB
Go
package docrevision
|
|
|
|
import (
|
|
"chat-agent-system/internal/app/uow"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// EmptyGenerationBaseline is an explicit pre-agent empty prior used only when the document
|
|
// has no prior ledger and no distinct prior markdown. It is not fabricated business content;
|
|
// it exists so the first agent materialization can produce a real before→after change report.
|
|
const EmptyGenerationBaseline = "# (生成前空基线)\n\n"
|
|
|
|
// AgentMaterializeRequest drives first-or-subsequent agent/squad ledger writes with a visible report.
|
|
type AgentMaterializeRequest struct {
|
|
DocID string
|
|
RunID string
|
|
ProposalID string
|
|
OutputMarkdown string
|
|
PriorMarkdown string // optional; when absent/equal to output, EmptyGenerationBaseline is used for first gen
|
|
ReportJSON string // optional; empty → deterministic fallback report (never invented PI prose)
|
|
ProjectID int64
|
|
WorkDir string
|
|
ActorKind string
|
|
ActorSnapshot ActorSnapshot
|
|
OnBehalfOf *int64
|
|
AutoCommitUserID int64
|
|
}
|
|
|
|
// AgentMaterializeResult returns ledger state plus the candidate/report produced for this run.
|
|
type AgentMaterializeResult struct {
|
|
State *RevisionState
|
|
Candidate *AgentCandidate
|
|
Report *ChangeReport
|
|
FirstGeneration bool
|
|
}
|
|
|
|
// MaterializeAgentOutput ensures an open ledger, creates an agent candidate with a deterministic
|
|
// change report (analysis_status=fallback when no PI narrative JSON), and optionally auto-commits
|
|
// when risk does not require confirmation. First generation always yields a visible report.
|
|
// Invalid or unassessed reports are never auto-committed even if a candidate row exists.
|
|
func (s *Service) MaterializeAgentOutput(ctx context.Context, req AgentMaterializeRequest) (*AgentMaterializeResult, error) {
|
|
if err := requireDocID(req.DocID); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(req.OutputMarkdown) == "" {
|
|
return nil, ErrNeedsContent
|
|
}
|
|
if req.ActorKind == "" {
|
|
req.ActorKind = ActorAgent
|
|
}
|
|
if req.RunID == "" {
|
|
req.RunID = fmt.Sprintf("agent-%d", time.Now().UnixNano())
|
|
}
|
|
|
|
first := false
|
|
st, err := s.GetRevisionState(ctx, req.DocID)
|
|
if errors.Is(err, ErrNotFound) {
|
|
first = true
|
|
prior := req.PriorMarkdown
|
|
priorCanon := CanonicalizeMarkdown(prior)
|
|
outCanon := CanonicalizeMarkdown(req.OutputMarkdown)
|
|
if strings.TrimSpace(priorCanon) == "" || SHA256Hex(priorCanon) == SHA256Hex(outCanon) {
|
|
prior = EmptyGenerationBaseline
|
|
}
|
|
if _, err := s.OpenTemplateVersion(ctx, req.DocID, prior, req.ActorSnapshot, req.ProjectID, req.WorkDir); err != nil {
|
|
return nil, err
|
|
}
|
|
st, err = s.GetRevisionState(ctx, req.DocID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else if err != nil {
|
|
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,
|
|
ActorKind: req.ActorKind, ActorSnapshot: req.ActorSnapshot, OnBehalfOf: req.OnBehalfOf,
|
|
})
|
|
if cand == nil {
|
|
return nil, err
|
|
}
|
|
out := &AgentMaterializeResult{Candidate: cand, Report: rep, FirstGeneration: first, State: st}
|
|
// Never auto-commit when report validation failed / unassessed, even if cand != nil.
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if rep != nil && rep.AnalysisStatus == AnalysisUnassessed {
|
|
return out, fmt.Errorf("%w: unassessed report cannot auto-commit", ErrReportInvalid)
|
|
}
|
|
if cand.Status == CandidateStale {
|
|
return out, ErrCandidateStale
|
|
}
|
|
if cand.Status == CandidateNeedsConfirmation {
|
|
// Report is visible via candidate/change-reports; do not auto-commit.
|
|
return out, nil
|
|
}
|
|
if req.AutoCommitUserID > 0 && cand.Status == CandidateReady {
|
|
st2, cErr := s.CommitAgentVersion(ctx, req.DocID, cand.ID, CommitAgentDecision{
|
|
Decision: "commit", Confirmed: true, ActorUserID: req.AutoCommitUserID,
|
|
ExpectedInputSHA: st.HeadSHA256,
|
|
})
|
|
if cErr != nil {
|
|
return out, cErr
|
|
}
|
|
out.State = st2
|
|
if c2, gErr := s.GetCandidate(ctx, req.DocID, cand.ID); gErr == nil {
|
|
out.Candidate = c2
|
|
}
|
|
if rep != nil && rep.ID > 0 {
|
|
if r2, gErr := s.GetChangeReport(ctx, req.DocID, rep.ID); gErr == nil {
|
|
out.Report = r2
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRequest) (*AgentCandidate, *ChangeReport, error) {
|
|
if err := requireDocID(req.DocID); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !s.AgentCommit {
|
|
return nil, nil, fmt.Errorf("docrevision: agent commit disabled")
|
|
}
|
|
canon := CanonicalizeMarkdown(req.OutputMarkdown)
|
|
if len(canon) > MaxMarkdownBytes {
|
|
return nil, nil, ErrTooLarge
|
|
}
|
|
outSHA := SHA256Hex(canon)
|
|
|
|
g := s.lockDoc(req.DocID)
|
|
defer g.Release()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if err := s.hydrateDocLocked(ctx, req.DocID); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
root := s.roots[req.DocID]
|
|
if root == nil || root.LedgerVersionID == nil || root.HeadSnapshotID == nil {
|
|
return nil, nil, ErrNoOpenVersion
|
|
}
|
|
if *root.HeadSnapshotID != req.InputSnapshotID {
|
|
// still create stale candidate
|
|
}
|
|
inSnap := s.snapshots[req.InputSnapshotID]
|
|
if inSnap == nil {
|
|
return nil, nil, ErrNotFound
|
|
}
|
|
inMD := s.blobs[inSnap.ContentSHA256].ContentMarkdown
|
|
outSnap := s.putSnapshotLocked(req.DocID, canon, outSHA, &req.InputSnapshotID, req.ActorKind, req.OnBehalfOf, req.RunID, req.ActorSnapshot, req.RunID, nil)
|
|
diff := ComputeDiff(inMD, canon, inSnap.BlockMap, outSnap.BlockMap)
|
|
impacts := s.impactLocked(ctx, req.DocID)
|
|
ruleRisk := RuleRiskFloor(diff.Hunks, impacts)
|
|
reportJSON := req.ReportJSON
|
|
if PIChangeReportNarrativeEnabled() && strings.TrimSpace(reportJSON) == "" && s.NarrativeFetcher != nil {
|
|
digest := NarrativeInputDigest(inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff.Hunks)
|
|
raw, ferr := s.NarrativeFetcher(ctx, NarrativeFetchSpec{
|
|
FromSHA: inSnap.ContentSHA256, ToSHA: outSHA, RuleRisk: ruleRisk,
|
|
InputDigest: digest, Impacts: impacts, Hunks: append([]DiffHunk(nil), diff.Hunks...),
|
|
})
|
|
if ferr == nil && strings.TrimSpace(raw) != "" {
|
|
reportJSON = raw
|
|
}
|
|
}
|
|
rep, err := ResolveChangeReport(reportJSON, inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff)
|
|
status := CandidateReady
|
|
if err != nil {
|
|
// Invalid reports stay visible as candidates but must not look "ready" for auto-commit.
|
|
// (Flag-off legacy path only; flag-on narrative failures already full-fallback with err=nil.)
|
|
status = CandidateNeedsConfirmation
|
|
if rep != nil {
|
|
rep.AnalysisStatus = AnalysisUnassessed
|
|
}
|
|
}
|
|
if NeedsConfirmation(rep.RiskLevel) {
|
|
status = CandidateNeedsConfirmation
|
|
}
|
|
rep.ID = nextFreeID(&s.nextRepID, func(id int64) bool { return s.reports[id] != nil })
|
|
rep.DocID = req.DocID
|
|
rep.FromSnapshotID = req.InputSnapshotID
|
|
rep.ToSnapshotID = outSnap.ID
|
|
rep.CreatedAt = time.Now().UTC()
|
|
rep.Authority = ReportProposed
|
|
rep.Lifecycle = ReportActive
|
|
var superseded []*ChangeReport
|
|
if *root.HeadSnapshotID != req.InputSnapshotID {
|
|
status = CandidateStale
|
|
rep.Lifecycle = ReportExpired
|
|
} else {
|
|
superseded = s.supersedeActiveProposedLocked(req.DocID)
|
|
}
|
|
stampReportDisposition(rep)
|
|
s.reports[rep.ID] = rep
|
|
|
|
c := &AgentCandidate{
|
|
ID: nextFreeID(&s.nextCandID, func(id int64) bool { return s.candidates[id] != nil }), DocID: req.DocID, InputSnapshotID: req.InputSnapshotID,
|
|
OutputSnapshotID: &outSnap.ID, RunID: req.RunID, ProposalID: req.ProposalID,
|
|
Status: status, AuthorizedScope: req.AuthorizedScope, ChangeReportID: &rep.ID,
|
|
ActorKind: req.ActorKind, ActorSnapshot: req.ActorSnapshot, CreatedAt: time.Now().UTC(),
|
|
}
|
|
s.candidates[c.ID] = c
|
|
if err := s.persistAgentCandidateLocked(ctx, outSnap, rep, c, superseded); err != nil {
|
|
s.forgetDocLocked(req.DocID)
|
|
return nil, nil, err
|
|
}
|
|
if s.Notify && s.OnOutbox != nil && status == CandidateNeedsConfirmation {
|
|
_ = s.OnOutbox(ctx, "document.agent_candidate_ready", map[string]any{
|
|
"doc_id": req.DocID, "candidate_id": c.ID, "risk": rep.RiskLevel,
|
|
})
|
|
}
|
|
if err != nil && status != CandidateStale {
|
|
return c, rep, err
|
|
}
|
|
return c, rep, nil
|
|
}
|
|
|
|
func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidateID int64, dec CommitAgentDecision) (*RevisionState, error) {
|
|
if err := requireDocID(docID); err != nil {
|
|
return nil, err
|
|
}
|
|
g := s.lockDoc(docID)
|
|
defer g.Release()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if err := s.hydrateDocLocked(ctx, docID); err != nil {
|
|
return nil, err
|
|
}
|
|
c, ok := s.candidates[candidateID]
|
|
if !ok || c.DocID != docID {
|
|
return nil, ErrNotFound
|
|
}
|
|
if c.Status == CandidateCommitted {
|
|
// Idempotent retry after projection/governance failure: same candidate + matching
|
|
// hashes may continue ProjectOutputMarkdown / MarkProposalApplied.
|
|
if dec.Decision == "reject" {
|
|
return nil, fmt.Errorf("docrevision: candidate already committed")
|
|
}
|
|
if c.OutputSnapshotID == nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
outSnap := s.snapshots[*c.OutputSnapshotID]
|
|
inSnap := s.snapshots[c.InputSnapshotID]
|
|
if outSnap == nil || inSnap == nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
if dec.ExpectedInputSHA != "" && dec.ExpectedInputSHA != inSnap.ContentSHA256 {
|
|
return nil, ErrCandidateStale
|
|
}
|
|
if dec.ExpectedOutputSHA != "" && dec.ExpectedOutputSHA != outSnap.ContentSHA256 {
|
|
return nil, ErrCandidateStale
|
|
}
|
|
return s.mustStateLocked(c.DocID), nil
|
|
}
|
|
if c.Status == CandidateStale {
|
|
return nil, ErrCandidateStale
|
|
}
|
|
if c.Status == CandidateRejected {
|
|
return nil, fmt.Errorf("docrevision: candidate rejected")
|
|
}
|
|
root := s.roots[c.DocID]
|
|
if root == nil || root.HeadSnapshotID == nil {
|
|
return nil, ErrNoOpenVersion
|
|
}
|
|
if *root.HeadSnapshotID != c.InputSnapshotID {
|
|
c.Status = CandidateStale
|
|
if c.ChangeReportID != nil {
|
|
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportExpired)
|
|
}
|
|
if s.usingPG() {
|
|
if err := s.withTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
|
|
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
|
|
return err
|
|
}
|
|
if c.ChangeReportID == nil {
|
|
return nil
|
|
}
|
|
return s.updateReportFactsTX(ctx, tx, s.reports[*c.ChangeReportID])
|
|
}); err != nil {
|
|
s.forgetDocLocked(docID)
|
|
return nil, err
|
|
}
|
|
}
|
|
return nil, ErrCandidateStale
|
|
}
|
|
if root.LifecycleState != "" && root.LifecycleState != "drafting" {
|
|
return nil, ErrLifecycleLocked
|
|
}
|
|
if c.ChangeReportID == nil || c.OutputSnapshotID == nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
rep := s.reports[*c.ChangeReportID]
|
|
outSnap := s.snapshots[*c.OutputSnapshotID]
|
|
inSnap := s.snapshots[c.InputSnapshotID]
|
|
if rep == nil || outSnap == nil || inSnap == nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
if dec.ExpectedInputSHA != "" && dec.ExpectedInputSHA != inSnap.ContentSHA256 {
|
|
return nil, ErrCandidateStale
|
|
}
|
|
if dec.ExpectedOutputSHA != "" && dec.ExpectedOutputSHA != outSnap.ContentSHA256 {
|
|
return nil, ErrCandidateStale
|
|
}
|
|
if dec.Decision == "reject" {
|
|
now := time.Now().UTC()
|
|
c.Status = CandidateRejected
|
|
c.DecidedAt = &now
|
|
if c.ChangeReportID != nil {
|
|
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportRejected)
|
|
}
|
|
if s.usingPG() {
|
|
if err := s.withTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
|
|
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
|
|
return err
|
|
}
|
|
if c.ChangeReportID == nil {
|
|
return nil
|
|
}
|
|
return s.updateReportFactsTX(ctx, tx, s.reports[*c.ChangeReportID])
|
|
}); err != nil {
|
|
s.forgetDocLocked(docID)
|
|
return nil, err
|
|
}
|
|
}
|
|
return s.mustStateLocked(c.DocID), nil
|
|
}
|
|
if NeedsConfirmation(rep.RiskLevel) && !dec.Confirmed {
|
|
return nil, ErrNeedsConfirmation
|
|
}
|
|
if inSnap.ContentSHA256 == outSnap.ContentSHA256 {
|
|
ver := s.versions[*root.LedgerVersionID]
|
|
eventNo := s.nextEventNoLocked(c.DocID)
|
|
ev := s.putEventLocked(c.DocID, ver.ID, eventNo, &c.InputSnapshotID, outSnap.ID, ChangeAgentNoop, c.ActorKind, dec.ActorUserIDPtr(), c.RunID, c.ActorSnapshot, nil, "", "", fmt.Sprintf("agent_noop:%s:%s", c.RunID, outSnap.ContentSHA256))
|
|
now := time.Now().UTC()
|
|
c.Status = CandidateCommitted
|
|
c.DecidedAt = &now
|
|
if c.ChangeReportID != nil {
|
|
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportCommitted)
|
|
}
|
|
if s.usingPG() {
|
|
if err := s.withTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
|
|
eid, err := s.insertEventTX(ctx, tx, ev)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.adoptEventID(ev, eid)
|
|
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
|
|
return err
|
|
}
|
|
if c.ChangeReportID == nil {
|
|
return nil
|
|
}
|
|
return s.updateReportFactsTX(ctx, tx, s.reports[*c.ChangeReportID])
|
|
}); err != nil {
|
|
s.forgetDocLocked(docID)
|
|
return nil, err
|
|
}
|
|
}
|
|
return s.mustStateLocked(c.DocID), nil
|
|
}
|
|
|
|
oldVer := s.versions[*root.LedgerVersionID]
|
|
now := time.Now().UTC()
|
|
oldVer.Status = VersionSealed
|
|
oldVer.SealedAt = &now
|
|
oldVer.HeadSnapshotID = c.InputSnapshotID
|
|
|
|
origin := OriginAgent
|
|
if c.ActorKind == ActorAgentSquad {
|
|
origin = OriginAgentSquad
|
|
}
|
|
newVer := s.openVersionLocked(c.DocID, origin, outSnap.ID, outSnap.ID, c.RunID)
|
|
auth := s.sealProposedWithSuccessorLocked(rep)
|
|
s.expireOtherProposedLocked(c.DocID, rep.ID)
|
|
newVer.ChangeReportID = &auth.ID
|
|
root.LedgerVersionID = &newVer.ID
|
|
root.HeadSnapshotID = &outSnap.ID
|
|
root.HeadRevisionNo = 0
|
|
root.ContentSHA256 = outSnap.ContentSHA256
|
|
root.RowRevision++
|
|
|
|
inMD := s.blobs[inSnap.ContentSHA256].ContentMarkdown
|
|
outMD := s.blobs[outSnap.ContentSHA256].ContentMarkdown
|
|
diff := ComputeDiff(inMD, outMD, inSnap.BlockMap, outSnap.BlockMap)
|
|
eventNo := s.nextEventNoLocked(c.DocID)
|
|
ev := s.putEventLocked(c.DocID, newVer.ID, eventNo, &c.InputSnapshotID, outSnap.ID, ChangeAgentCommit, c.ActorKind, dec.ActorUserIDPtr(), c.RunID, c.ActorSnapshot, diff.Hunks, "", "", fmt.Sprintf("agent_commit:%s:%s", c.RunID, outSnap.ContentSHA256))
|
|
c.Status = CandidateCommitted
|
|
c.DecidedAt = &now
|
|
|
|
if err := s.persistAgentCommitLocked(ctx, root, oldVer, newVer, outSnap, ev, c); err != nil {
|
|
s.forgetDocLocked(docID)
|
|
return nil, err
|
|
}
|
|
if s.OnFinalArtifact != nil && (!s.usingPG() || s.OnFinalArtifactTX == nil) {
|
|
eventID := fmt.Sprintf("ev:%d", ev.ID)
|
|
s.OnFinalArtifact(ctx, "agent_commit", c.DocID, root.ProjectID, outSnap.ID, eventID, []byte(outMD), nil)
|
|
if oldVer != nil && oldVer.SealedAt != nil {
|
|
s.OnFinalArtifact(ctx, "version_sealed", c.DocID, root.ProjectID, oldVer.HeadSnapshotID, eventID+":sealed", []byte(inMD), oldVer.SealedAt)
|
|
}
|
|
}
|
|
|
|
if _, joined := uow.ScopeFrom(ctx); !joined && s.Notify && s.OnOutbox != nil && auth.ID != 0 {
|
|
// document.agent_version_committed is owned by events.EventSink (U6) and is not emitted here.
|
|
// Downstream notices belong to the authoritative report, never to the proposed estimate.
|
|
for _, imp := range auth.DocumentImpacts {
|
|
_ = s.OnOutbox(ctx, "document.downstream_impact_detected", map[string]any{
|
|
"doc_id": c.DocID, "impacted_doc_id": imp.DocID, "version_no": newVer.VersionNo,
|
|
})
|
|
}
|
|
if auth.AnalysisStatus == AnalysisUnassessed || auth.AnalysisStatus == AnalysisFallback {
|
|
_ = s.OnOutbox(ctx, "document.change_report_degraded", map[string]any{
|
|
"doc_id": c.DocID, "report_id": auth.ID,
|
|
})
|
|
}
|
|
}
|
|
return s.mustStateLocked(c.DocID), nil
|
|
}
|
|
|
|
func (d CommitAgentDecision) ActorUserIDPtr() *int64 {
|
|
if d.ActorUserID == 0 {
|
|
return nil
|
|
}
|
|
id := d.ActorUserID
|
|
return &id
|
|
}
|