Files
Agent-System/internal/docrevision/service.go
T
weichenandCursor 2d0fcb94bc fix(docrevision): keep PG snapshot IDs ahead of in-memory counters
Opening a template stored database IDs without advancing nextSnapID, so the next temporary snapshot could overwrite and then delete the real baseline. Adopt helpers now bump every counter and only drop a map entry when it still points at the same object.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 23:43:56 +00:00

1118 lines
35 KiB
Go

package docrevision
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Service is the document revision ledger.
type Service struct {
pool *pgxpool.Pool
mu sync.Mutex
blobs map[string]*ContentBlob
snapshots map[int64]*Snapshot
versions map[int64]*Version
events map[int64]*ChangeEvent
candidates map[int64]*AgentCandidate
reports map[int64]*ChangeReport
roots map[string]*DocRootPointers
idemp map[string]int64 // idempotency_key -> event id
nextSnapID int64
nextVerID int64
nextEvtID int64
nextCandID int64
nextRepID int64
// Outbox hooks (Wave 5); optional.
OnOutbox func(ctx context.Context, eventType string, payload map[string]any) error
// ImpactResolver returns deterministic downstream impacts (lineage); optional.
ImpactResolver func(ctx context.Context, docID string) []DocImpact
// DataDir is the environment data root used to resolve relative WorkDir paths.
DataDir string
// Feature flags
LedgerWrite bool
LedgerRead bool
AgentCommit bool
DiffUI bool
Notify bool
}
func NewService(pool *pgxpool.Pool) *Service {
return &Service{
pool: pool,
blobs: map[string]*ContentBlob{},
snapshots: map[int64]*Snapshot{},
versions: map[int64]*Version{},
events: map[int64]*ChangeEvent{},
candidates: map[int64]*AgentCandidate{},
reports: map[int64]*ChangeReport{},
roots: map[string]*DocRootPointers{},
idemp: map[string]int64{},
LedgerWrite: true,
LedgerRead: true,
AgentCommit: true,
DiffUI: true,
Notify: true,
}
}
func (s *Service) EnsureRoot(docID, kind string, projectID int64, lifecycle string) *DocRootPointers {
s.mu.Lock()
defer s.mu.Unlock()
if r, ok := s.roots[docID]; ok {
return r
}
r := &DocRootPointers{
DocID: docID,
Kind: kind,
LifecycleState: lifecycle,
ProjectID: projectID,
RevisionSchemaVer: SchemaVersion,
}
s.roots[docID] = r
return r
}
func requireDocID(docID string) error {
if strings.TrimSpace(docID) == "" {
return ErrInvalidDocID
}
return nil
}
func (s *Service) GetRevisionState(ctx context.Context, docID string) (*RevisionState, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
return nil, err
}
root, ok := s.roots[docID]
if !ok || root == nil || root.LedgerVersionID == nil || root.HeadSnapshotID == nil {
return nil, ErrNotFound
}
ver := s.versions[*root.LedgerVersionID]
head := s.snapshots[*root.HeadSnapshotID]
if ver == nil || head == nil {
return nil, ErrNotFound
}
caps := []string{"read"}
if root.LifecycleState == "drafting" {
caps = append(caps, "edit")
}
if s.DiffUI {
caps = append(caps, "diff", "overlay", "history")
}
st := &RevisionState{
DocID: docID,
CurrentVersionNo: ver.VersionNo,
VersionStatus: ver.Status,
BaselineSnapshotID: ver.BaselineSnapshotID,
HeadSnapshotID: ver.HeadSnapshotID,
HeadRevisionNo: root.HeadRevisionNo,
HeadSHA256: head.ContentSHA256,
ManualChangeCount: ver.HumanRevisionCount,
Capabilities: caps,
ETag: fmt.Sprintf("%d:%d", ver.HeadSnapshotID, root.HeadRevisionNo),
HeadBlockMap: append([]BlockNode(nil), head.BlockMap...),
}
s.attachPendingCandidateLocked(st, docID)
return st, nil
}
// EnsureBootstrap creates a ledger from fact-source markdown when none exists (idempotent).
func (s *Service) EnsureBootstrap(ctx context.Context, req BootstrapRequest) (*RevisionState, error) {
if err := requireDocID(req.DocID); err != nil {
return nil, err
}
if st, err := s.GetRevisionState(ctx, req.DocID); err == nil {
return st, nil
} else if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
md := strings.TrimSpace(req.Markdown)
if md == "" {
return nil, ErrNeedsContent
}
kind := req.Kind
if kind == "" {
kind = "controlled"
}
life := req.LifecycleState
if life == "" {
life = "drafting"
}
st, err := s.OpenTemplateVersion(ctx, req.DocID, md, req.Actor, req.ProjectID, req.WorkDir)
if err != nil {
return nil, err
}
s.mu.Lock()
if root := s.roots[req.DocID]; root != nil {
root.Kind = kind
root.LifecycleState = life
}
s.mu.Unlock()
return st, nil
}
func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequest) (*HumanRevisionResult, error) {
if err := requireDocID(req.DocID); err != nil {
return nil, err
}
if req.IdempotencyKey == "" {
return nil, fmt.Errorf("docrevision: idempotency key required")
}
if req.LifecycleState != "" && req.LifecycleState != "drafting" {
return nil, ErrLifecycleLocked
}
canon := CanonicalizeMarkdown(req.Markdown)
if len(canon) > MaxMarkdownBytes {
return nil, ErrTooLarge
}
sha := SHA256Hex(canon)
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, req.DocID); err != nil {
return nil, err
}
if eid, ok := s.idemp[req.IdempotencyKey]; ok {
ev := s.events[eid]
st, _ := s.revisionStateLocked(req.DocID)
overlay := s.manualOverlayLocked(req.DocID)
return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay}, nil
}
root := s.roots[req.DocID]
if root == nil {
kind := "controlled"
if req.WorkDir == "" && req.ProjectID > 0 {
kind = "controlled"
}
root = &DocRootPointers{DocID: req.DocID, Kind: kind, LifecycleState: req.LifecycleState, ProjectID: req.ProjectID, RevisionSchemaVer: SchemaVersion, WorkDir: req.WorkDir}
s.roots[req.DocID] = root
}
if req.WorkDir != "" {
root.WorkDir = req.WorkDir
}
if req.ProjectID > 0 {
root.ProjectID = req.ProjectID
}
if root.LedgerVersionID == nil {
if !req.AllowCreateBaseline {
return nil, ErrNoOpenVersion
}
snap := s.putSnapshotLocked(req.DocID, canon, sha, nil, SourceHuman, &req.ActorUserID, "", req.ActorSnapshot, "", req.ClientBlockMap)
ver := s.openVersionLocked(req.DocID, OriginTemplate, snap.ID, snap.ID, "")
root.LedgerVersionID = &ver.ID
root.HeadSnapshotID = &snap.ID
root.HeadRevisionNo = 0
root.ContentSHA256 = sha
root.RowRevision++
ev := s.putEventLocked(req.DocID, ver.ID, 1, nil, snap.ID, ChangeImport, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, nil, req.EditSessionID, req.RequestID, req.IdempotencyKey)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, true); err != nil {
s.forgetDocLocked(req.DocID)
return nil, err
}
st := s.mustStateLocked(req.DocID)
return &HumanRevisionResult{State: *st, Event: *ev}, nil
}
ver := s.versions[*root.LedgerVersionID]
if ver.Status != VersionOpen {
return nil, ErrNoOpenVersion
}
if req.ExpectedHeadSnapshot != nil && *req.ExpectedHeadSnapshot != ver.HeadSnapshotID {
cur := s.snapshots[ver.HeadSnapshotID]
return &HumanRevisionResult{Conflict: &ConflictInfo{
Code: ErrCodeHeadConflict, BaseSnapshotID: ver.BaselineSnapshotID,
CurrentSnapshotID: ver.HeadSnapshotID, CurrentRevisionNo: root.HeadRevisionNo, CurrentSHA256: cur.ContentSHA256,
}}, ErrHeadConflict
}
if req.ExpectedRevisionNo != nil && *req.ExpectedRevisionNo != root.HeadRevisionNo {
cur := s.snapshots[ver.HeadSnapshotID]
return &HumanRevisionResult{Conflict: &ConflictInfo{
Code: ErrCodeHeadConflict, BaseSnapshotID: ver.BaselineSnapshotID,
CurrentSnapshotID: ver.HeadSnapshotID, CurrentRevisionNo: root.HeadRevisionNo, CurrentSHA256: cur.ContentSHA256,
}}, ErrHeadConflict
}
parentID := ver.HeadSnapshotID
parent := s.snapshots[parentID]
if parent.ContentSHA256 == sha {
st := s.mustStateLocked(req.DocID)
overlay := s.manualOverlayLocked(req.DocID)
return &HumanRevisionResult{State: *st, Overlay: overlay}, nil
}
prevBlocks := parent.BlockMap
snap := s.putSnapshotLocked(req.DocID, canon, sha, &parentID, SourceHuman, &req.ActorUserID, "", req.ActorSnapshot, "", req.ClientBlockMap)
if len(req.ClientBlockMap) == 0 {
snap.BlockMap = BuildBlockMap(canon, prevBlocks)
} else {
snap.BlockMap = BuildBlockMap(canon, req.ClientBlockMap)
}
diff := ComputeDiff(s.blobs[parent.ContentSHA256].ContentMarkdown, canon, prevBlocks, snap.BlockMap)
ver.HeadSnapshotID = snap.ID
ver.HumanRevisionCount++
root.HeadSnapshotID = &snap.ID
root.HeadRevisionNo++
root.ContentSHA256 = sha
root.RowRevision++
eventNo := s.nextEventNoLocked(req.DocID)
ev := s.putEventLocked(req.DocID, ver.ID, eventNo, &parentID, snap.ID, ChangeHumanRevision, ActorHuman, &req.ActorUserID, "", req.ActorSnapshot, diff.Hunks, req.EditSessionID, req.RequestID, req.IdempotencyKey)
if err := s.persistHumanRevisionLocked(ctx, root, ver, snap, ev, false); err != nil {
s.forgetDocLocked(req.DocID)
return nil, err
}
st := s.mustStateLocked(req.DocID)
baseMD := s.blobs[s.snapshots[ver.BaselineSnapshotID].ContentSHA256].ContentMarkdown
overlay := ComputeDiff(baseMD, canon, s.snapshots[ver.BaselineSnapshotID].BlockMap, snap.BlockMap)
overlay.FromSnapshotID = ver.BaselineSnapshotID
overlay.ToSnapshotID = snap.ID
return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay}, nil
}
func (s *Service) OpenTemplateVersion(ctx context.Context, docID string, markdown string, actor ActorSnapshot, projectID int64, workDir string) (*RevisionState, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
canon := CanonicalizeMarkdown(markdown)
sha := SHA256Hex(canon)
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
return nil, err
}
if root, ok := s.roots[docID]; ok && root.LedgerVersionID != nil {
return s.mustStateLocked(docID), nil
}
if s.usingPG() {
return s.openTemplatePGLocked(ctx, docID, canon, sha, actor, projectID, workDir)
}
snap := s.putSnapshotLocked(docID, canon, sha, nil, SourceTemplate, nil, "template", actor, "", nil)
ver := s.openVersionLocked(docID, OriginTemplate, snap.ID, snap.ID, "")
root := &DocRootPointers{
DocID: docID, Kind: "controlled", ProjectID: projectID, WorkDir: workDir,
LedgerVersionID: &ver.ID, HeadSnapshotID: &snap.ID, HeadRevisionNo: 0,
ContentSHA256: sha, LifecycleState: "drafting", RevisionSchemaVer: SchemaVersion,
}
s.roots[docID] = root
_ = s.putEventLocked(docID, ver.ID, 1, nil, snap.ID, ChangeImport, ActorTemplate, nil, "template", actor, nil, "", "", fmt.Sprintf("template:%s:%s", docID, sha))
return s.mustStateLocked(docID), nil
}
// 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)
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 strings.TrimSpace(reportJSON) == "" {
reportJSON = BuildDeterministicChangeReportJSON(inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff)
}
rep, err := ValidateChangeReportJSON(reportJSON, inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff.Hunks)
status := CandidateReady
if err != nil {
// Invalid reports stay visible as candidates but must not look "ready" for auto-commit.
status = CandidateNeedsConfirmation
if rep != nil {
rep.AnalysisStatus = AnalysisUnassessed
}
}
if strings.TrimSpace(req.ReportJSON) == "" && rep != nil && rep.AnalysisStatus == AnalysisComplete {
rep.AnalysisStatus = AnalysisFallback
}
if NeedsConfirmation(rep.RiskLevel) {
status = CandidateNeedsConfirmation
}
if *root.HeadSnapshotID != req.InputSnapshotID {
status = CandidateStale
}
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()
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); 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
}
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
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 s.usingPG() {
if err := s.withTx(ctx, func(tx pgx.Tx) error { return s.updateCandidateTX(ctx, tx, c) }); 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 s.usingPG() {
if err := s.withTx(ctx, func(tx pgx.Tx) error {
eid, err := s.insertEventTX(ctx, tx, ev)
if err != nil {
return err
}
s.adoptEventID(ev, eid)
return s.updateCandidateTX(ctx, tx, c)
}); 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)
newVer.ChangeReportID = c.ChangeReportID
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.Notify && s.OnOutbox != nil {
_ = s.OnOutbox(ctx, "document.agent_version_committed", map[string]any{
"doc_id": c.DocID, "version_no": newVer.VersionNo, "risk": rep.RiskLevel, "candidate_id": c.ID,
})
for _, imp := range rep.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 rep.AnalysisStatus == AnalysisUnassessed || rep.AnalysisStatus == AnalysisFallback {
_ = s.OnOutbox(ctx, "document.change_report_degraded", map[string]any{
"doc_id": c.DocID, "report_id": rep.ID,
})
}
}
return s.mustStateLocked(c.DocID), nil
}
func (d CommitAgentDecision) ActorUserIDPtr() *int64 {
if d.ActorUserID == 0 {
return nil
}
id := d.ActorUserID
return &id
}
func (s *Service) ListVersions(ctx context.Context, docID string) ([]Version, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
return nil, err
}
var out []Version
for _, v := range s.versions {
if v.DocID == docID {
out = append(out, *v)
}
}
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if out[j].VersionNo < out[i].VersionNo {
out[i], out[j] = out[j], out[i]
}
}
}
return out, nil
}
func (s *Service) GetVersionContent(ctx context.Context, docID string, versionNo int, view string) (string, string, error) {
if err := requireDocID(docID); err != nil {
return "", "", err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
return "", "", err
}
var ver *Version
for _, v := range s.versions {
if v.DocID == docID && v.VersionNo == versionNo {
ver = v
break
}
}
if ver == nil {
return "", "", ErrNotFound
}
sid := ver.HeadSnapshotID
if view == "baseline" {
sid = ver.BaselineSnapshotID
}
snap := s.snapshots[sid]
if snap == nil || s.blobs[snap.ContentSHA256] == nil {
return "", "", ErrNotFound
}
return s.blobs[snap.ContentSHA256].ContentMarkdown, snap.ContentSHA256, nil
}
func (s *Service) DiffVersions(ctx context.Context, docID string, fromNo, toNo int, fromView, toView string) (*DiffResult, error) {
fromMD, _, err := s.GetVersionContent(ctx, docID, fromNo, fromView)
if err != nil {
return nil, err
}
toMD, _, err := s.GetVersionContent(ctx, docID, toNo, toView)
if err != nil {
return nil, err
}
fromBlocks := BuildBlockMap(fromMD, nil)
toBlocks := BuildBlockMap(toMD, fromBlocks)
d := ComputeDiff(fromMD, toMD, fromBlocks, toBlocks)
return &d, nil
}
func (s *Service) ManualOverlay(ctx context.Context, docID string) (*DiffResult, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
return nil, err
}
d := s.manualOverlayLocked(docID)
return &d, nil
}
func (s *Service) GetCandidate(ctx context.Context, docID string, candidateID int64) (*AgentCandidate, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
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
}
cp := *c
return &cp, nil
}
func (s *Service) GetChangeReport(ctx context.Context, docID string, reportID int64) (*ChangeReport, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
return nil, err
}
r, ok := s.reports[reportID]
if !ok || r.DocID != docID {
return nil, ErrNotFound
}
cp := *r
return &cp, nil
}
// ImportFreeformLegacy imports legacy freeform checkpoint as sealed migration versions + open head.
func (s *Service) ImportFreeformLegacy(ctx context.Context, docID string, ownerID int64, projectID int64, checkpoints []struct {
VersionNo int
Markdown string
SHA string
}, currentMarkdown string) (*RevisionState, error) {
if err := requireDocID(docID); err != nil {
return nil, err
}
_ = ctx
s.mu.Lock()
defer s.mu.Unlock()
if root, ok := s.roots[docID]; ok && root.LedgerVersionID != nil {
return s.mustStateLocked(docID), nil
}
actor := ActorSnapshot{DisplayName: "migration", Role: "system"}
var lastSnap *Snapshot
startNo := 1
for _, cp := range checkpoints {
canon := CanonicalizeMarkdown(cp.Markdown)
sha := cp.SHA
if sha == "" {
sha = SHA256Hex(canon)
}
var parent *int64
if lastSnap != nil {
id := lastSnap.ID
parent = &id
}
snap := s.putSnapshotLocked(docID, canon, sha, parent, SourceMigration, &ownerID, "legacy", actor, "", nil)
ver := s.openVersionLocked(docID, OriginMigration, snap.ID, snap.ID, "")
// force version number and seal
ver.VersionNo = cp.VersionNo
now := time.Now().UTC()
ver.Status = VersionSealed
ver.SealedAt = &now
lastSnap = snap
if cp.VersionNo >= startNo {
startNo = cp.VersionNo + 1
}
}
canon := CanonicalizeMarkdown(currentMarkdown)
sha := SHA256Hex(canon)
var parent *int64
if lastSnap != nil {
id := lastSnap.ID
parent = &id
}
snap := s.putSnapshotLocked(docID, canon, sha, parent, SourceMigration, &ownerID, "legacy_head", actor, "", nil)
ver := &Version{
ID: 0, DocID: docID, VersionNo: startNo, Status: VersionOpen, OriginKind: OriginMigration,
BaselineSnapshotID: snap.ID, HeadSnapshotID: snap.ID, OpenedAt: time.Now().UTC(),
}
ver.ID = nextFreeID(&s.nextVerID, func(id int64) bool { return s.versions[id] != nil })
s.versions[ver.ID] = ver
root := &DocRootPointers{
DocID: docID, Kind: "freeform", ProjectID: projectID,
LedgerVersionID: &ver.ID, HeadSnapshotID: &snap.ID, HeadRevisionNo: 0,
ContentSHA256: sha, LifecycleState: "drafting", RevisionSchemaVer: SchemaVersion,
LegacyVersionID: startNo - 1,
}
s.roots[docID] = root
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()
root := s.roots[docID]
if root == nil || root.HeadSnapshotID == nil || root.WorkDir == "" {
s.mu.Unlock()
return nil
}
snap := s.snapshots[*root.HeadSnapshotID]
md := s.blobs[snap.ContentSHA256].ContentMarkdown
workDir := root.WorkDir
dataDir := s.DataDir
s.mu.Unlock()
path := workDir
if !filepath.IsAbs(path) {
base := repoRoot
if dataDir != "" {
base = dataDir
}
path = filepath.Join(base, workDir)
}
if err := os.MkdirAll(path, 0o755); err != nil {
return err
}
return os.WriteFile(filepath.Join(path, "output.md"), []byte(md), 0o644)
}
// --- internal helpers ---
func (s *Service) putSnapshotLocked(docID, canon, sha string, parent *int64, source string, userID *int64, actorRef string, actor ActorSnapshot, runID string, clientBlocks []BlockNode) *Snapshot {
if _, ok := s.blobs[sha]; !ok {
s.blobs[sha] = &ContentBlob{ContentSHA256: sha, ContentMarkdown: canon, ByteSize: int64(len(canon)), CreatedAt: time.Now().UTC()}
}
var prev []BlockNode
if parent != nil {
if p := s.snapshots[*parent]; p != nil {
prev = p.BlockMap
}
}
blocks := clientBlocks
if len(blocks) == 0 {
blocks = BuildBlockMap(canon, prev)
} else {
blocks = BuildBlockMap(canon, clientBlocks)
}
snap := &Snapshot{
ID: nextFreeID(&s.nextSnapID, func(id int64) bool { return s.snapshots[id] != nil }), DocID: docID, ContentSHA256: sha, ParentSnapshotID: parent,
BlockMap: blocks, SourceKind: source, ActorUserID: userID, ActorRef: actorRef,
ActorSnapshot: actor, SourceRunID: runID, CreatedAt: time.Now().UTC(),
}
s.snapshots[snap.ID] = snap
return snap
}
func (s *Service) openVersionLocked(docID, origin string, baselineID, headID int64, runID string) *Version {
maxNo := 0
for _, v := range s.versions {
if v.DocID == docID && v.VersionNo > maxNo {
maxNo = v.VersionNo
}
if v.DocID == docID && v.Status == VersionOpen {
now := time.Now().UTC()
v.Status = VersionSealed
v.SealedAt = &now
}
}
ver := &Version{
ID: nextFreeID(&s.nextVerID, func(id int64) bool { return s.versions[id] != nil }), DocID: docID, VersionNo: maxNo + 1, Status: VersionOpen,
OriginKind: origin, BaselineSnapshotID: baselineID, HeadSnapshotID: headID,
SourceRunID: runID, OpenedAt: time.Now().UTC(),
}
s.versions[ver.ID] = ver
return ver
}
func (s *Service) putEventLocked(docID string, versionID int64, eventNo int, from *int64, to int64, kind, actorKind string, userID *int64, actorRef string, actor ActorSnapshot, hunks []DiffHunk, editSession, requestID, idemp string) *ChangeEvent {
ev := &ChangeEvent{
ID: nextFreeID(&s.nextEvtID, func(id int64) bool { return s.events[id] != nil }), DocID: docID, VersionID: versionID, EventNo: eventNo,
FromSnapshotID: from, ToSnapshotID: to, ChangeKind: kind, ActorKind: actorKind,
ActorUserID: userID, ActorRef: actorRef, ActorSnapshot: actor, ChangeSet: hunks,
EditSessionID: editSession, RequestID: requestID, IdempotencyKey: idemp, CreatedAt: time.Now().UTC(),
}
s.events[ev.ID] = ev
if idemp != "" {
s.idemp[idemp] = ev.ID
}
return ev
}
func (s *Service) nextEventNoLocked(docID string) int {
max := 0
for _, e := range s.events {
if e.DocID == docID && e.EventNo > max {
max = e.EventNo
}
}
return max + 1
}
func (s *Service) revisionStateLocked(docID string) (*RevisionState, error) {
root, ok := s.roots[docID]
if !ok || root.LedgerVersionID == nil {
return nil, ErrNotFound
}
return s.mustStateLocked(docID), nil
}
func (s *Service) mustStateLocked(docID string) *RevisionState {
root := s.roots[docID]
ver := s.versions[*root.LedgerVersionID]
head := s.snapshots[*root.HeadSnapshotID]
st := &RevisionState{
DocID: docID, CurrentVersionNo: ver.VersionNo, VersionStatus: ver.Status,
BaselineSnapshotID: ver.BaselineSnapshotID, HeadSnapshotID: ver.HeadSnapshotID,
HeadRevisionNo: root.HeadRevisionNo, HeadSHA256: head.ContentSHA256,
ManualChangeCount: ver.HumanRevisionCount,
Capabilities: []string{"read", "edit", "diff", "overlay", "history"},
ETag: fmt.Sprintf("%d:%d", ver.HeadSnapshotID, root.HeadRevisionNo),
}
if head != nil {
st.HeadBlockMap = append([]BlockNode(nil), head.BlockMap...)
}
s.attachPendingCandidateLocked(st, docID)
return st
}
// attachPendingCandidateLocked fills the latest needs_confirmation candidate onto revision state.
// Candidates awaiting confirmation are not version rows yet — UI must discover them here.
func (s *Service) attachPendingCandidateLocked(st *RevisionState, docID string) {
if st == nil || docID == "" {
return
}
var best *AgentCandidate
for _, c := range s.candidates {
if c == nil || c.DocID != docID || c.Status != CandidateNeedsConfirmation {
continue
}
if best == nil || c.ID > best.ID {
cp := *c
best = &cp
}
}
if best == nil {
return
}
id := best.ID
st.PendingCandidateID = &id
st.PendingCandidateStatus = best.Status
if best.ChangeReportID != nil {
rid := *best.ChangeReportID
st.PendingChangeReportID = &rid
if rep := s.reports[rid]; rep != nil {
st.PendingRiskLevel = rep.RiskLevel
}
}
}
// forgetDocLocked drops in-memory ledger rows for docID so a failed TX cannot linger as success.
func (s *Service) forgetDocLocked(docID string) {
if docID == "" {
return
}
delete(s.roots, docID)
for id, v := range s.versions {
if v != nil && v.DocID == docID {
delete(s.versions, id)
}
}
for id, sn := range s.snapshots {
if sn != nil && sn.DocID == docID {
delete(s.snapshots, id)
}
}
for id, ev := range s.events {
if ev != nil && ev.DocID == docID {
if ev.IdempotencyKey != "" {
delete(s.idemp, ev.IdempotencyKey)
}
delete(s.events, id)
}
}
for id, c := range s.candidates {
if c != nil && c.DocID == docID {
delete(s.candidates, id)
}
}
for id, r := range s.reports {
if r != nil && r.DocID == docID {
delete(s.reports, id)
}
}
}
func (s *Service) manualOverlayLocked(docID string) DiffResult {
root := s.roots[docID]
if root == nil || root.LedgerVersionID == nil {
return DiffResult{}
}
ver := s.versions[*root.LedgerVersionID]
base := s.snapshots[ver.BaselineSnapshotID]
head := s.snapshots[ver.HeadSnapshotID]
d := ComputeDiff(s.blobs[base.ContentSHA256].ContentMarkdown, s.blobs[head.ContentSHA256].ContentMarkdown, base.BlockMap, head.BlockMap)
d.FromSnapshotID = base.ID
d.ToSnapshotID = head.ID
return d
}
func (s *Service) impactLocked(ctx context.Context, docID string) []DocImpact {
if s.ImpactResolver != nil {
return s.ImpactResolver(ctx, docID)
}
return nil
}
// SetLifecycle updates lifecycle gate used by write paths.
func (s *Service) SetLifecycle(docID, state string) {
s.mu.Lock()
defer s.mu.Unlock()
if r := s.roots[docID]; r != nil {
r.LifecycleState = state
}
}