Files
Agent-System/internal/docrevision/report_disposition.go
T
weichen 5a5a91dd7f feat(docrevision): keep candidate estimates separate from committed reports
A candidate used the same report later attached to the version, so a preview could be treated as the formal result. Proposed reports now close on replace or reject, and only the commit writes an authoritative successor.
2026-09-20 05:15:41 +00:00

157 lines
4.2 KiB
Go

package docrevision
// AllowsSuggestedActions is true only for the report a user may still act on.
// A proposed estimate stops being actionable once it is superseded, rejected,
// expired, or replaced by an authoritative successor. The successor itself remains actionable.
func (r *ChangeReport) AllowsSuggestedActions() bool {
if r == nil {
return false
}
switch {
case r.Authority == ReportProposed && r.Lifecycle == ReportActive:
return true
case r.Authority == ReportAuthoritative && r.Lifecycle == ReportCommitted:
return true
default:
return false
}
}
func stampReportDisposition(rep *ChangeReport) {
if rep == nil {
return
}
if rep.DeterministicFacts == nil {
rep.DeterministicFacts = map[string]any{}
}
if rep.Authority != "" {
rep.DeterministicFacts["report_authority"] = rep.Authority
}
if rep.Lifecycle != "" {
rep.DeterministicFacts["report_lifecycle"] = rep.Lifecycle
}
if rep.SuccessorReportID != nil {
rep.DeterministicFacts["successor_report_id"] = *rep.SuccessorReportID
}
}
func liftReportDisposition(rep *ChangeReport) {
if rep == nil || rep.DeterministicFacts == nil {
return
}
if rep.Authority == "" {
if a, ok := rep.DeterministicFacts["report_authority"].(string); ok {
rep.Authority = a
}
}
if rep.Lifecycle == "" {
if life, ok := rep.DeterministicFacts["report_lifecycle"].(string); ok {
rep.Lifecycle = life
}
}
if rep.SuccessorReportID == nil {
if id, ok := factInt64(rep.DeterministicFacts["successor_report_id"]); ok {
rep.SuccessorReportID = &id
}
}
}
func factInt64(v any) (int64, bool) {
switch n := v.(type) {
case int64:
return n, true
case int:
return int64(n), true
case float64:
return int64(n), true
case jsonNumber:
i, err := n.Int64()
return i, err == nil
default:
return 0, false
}
}
// jsonNumber is the subset of encoding/json.Number used after a UseNumber decode.
type jsonNumber interface {
Int64() (int64, error)
}
func cloneChangeReport(src *ChangeReport) *ChangeReport {
if src == nil {
return nil
}
cp := *src
cp.ID = 0
cp.SuccessorReportID = nil
cp.Summary = append([]string(nil), src.Summary...)
cp.RiskItems = append([]RiskItem(nil), src.RiskItems...)
cp.DocumentImpacts = append([]DocImpact(nil), src.DocumentImpacts...)
cp.OpenQuestions = append([]string(nil), src.OpenQuestions...)
cp.DeterministicFacts = map[string]any{}
for k, v := range src.DeterministicFacts {
cp.DeterministicFacts[k] = v
}
if src.ProjectImpacts != nil {
cp.ProjectImpacts = map[string]any{}
for k, v := range src.ProjectImpacts {
cp.ProjectImpacts[k] = v
}
}
return &cp
}
func (s *Service) assignReportIDLocked(rep *ChangeReport) {
rep.ID = nextFreeID(&s.nextRepID, func(id int64) bool { return s.reports[id] != nil })
s.reports[rep.ID] = rep
}
// supersedeActiveProposedLocked closes earlier estimates for the same document.
// The new candidate is the only active proposed report.
func (s *Service) supersedeActiveProposedLocked(docID string) []*ChangeReport {
var out []*ChangeReport
for _, rep := range s.reports {
if rep == nil || rep.DocID != docID || rep.Authority != ReportProposed || rep.Lifecycle != ReportActive {
continue
}
rep.Lifecycle = ReportSuperseded
stampReportDisposition(rep)
out = append(out, rep)
}
return out
}
func (s *Service) sealProposedWithSuccessorLocked(proposed *ChangeReport) *ChangeReport {
auth := cloneChangeReport(proposed)
auth.Authority = ReportAuthoritative
auth.Lifecycle = ReportCommitted
auth.SuccessorReportID = nil
auth.CreatedAt = proposed.CreatedAt
s.assignReportIDLocked(auth)
proposed.Lifecycle = ReportCommitted
proposed.SuccessorReportID = &auth.ID
stampReportDisposition(proposed)
stampReportDisposition(auth)
return auth
}
func (s *Service) closeProposedLocked(rep *ChangeReport, lifecycle string) {
if rep == nil || rep.Authority != ReportProposed || rep.Lifecycle != ReportActive {
return
}
rep.Lifecycle = lifecycle
stampReportDisposition(rep)
}
func (s *Service) expireOtherProposedLocked(docID string, keep int64) {
for _, rep := range s.reports {
if rep == nil || rep.ID == keep || rep.DocID != docID {
continue
}
if rep.Authority == ReportProposed && rep.Lifecycle == ReportActive {
rep.Lifecycle = ReportExpired
stampReportDisposition(rep)
}
}
}