Files
Agent-System/internal/docrevision/service.go
T
weichenandCursor 952a94b6d5 fix(docrevision): ack content projection only after the fact source is written
The human ledger and the document body are different stores. Record the projection intent in the same transaction as the ledger, and clear it only when the body write succeeds so a retry cannot append a second revision.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 09:35:14 +00:00

1126 lines
36 KiB
Go

package docrevision
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"chat-agent-system/internal/app/uow"
"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
projections map[string]ContentProjection
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
// NarrativeFetcher optionally loads typed PI narrative JSON when
// PI_CHANGE_REPORT_NARRATIVE is on and ReportJSON is empty.
NarrativeFetcher func(ctx context.Context, spec NarrativeFetchSpec) (string, error)
// DataDir is the environment data root used to resolve relative WorkDir paths.
DataDir string
// StoredDataRoot / HostDataRoot rewrite container paths (e.g. /app/data)
// to a host data root for CLI live mode. Empty means no rewrite.
StoredDataRoot string
HostDataRoot 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{},
projections: map[string]ContentProjection{},
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()
if req.WorkDir != "" {
if pErr := s.ProjectOutputMarkdownIfSafe(req.DocID, s.DataDir); pErr != nil {
return st, pErr
}
}
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]
if ev != nil {
if snap := s.snapshots[ev.ToSnapshotID]; snap != nil && snap.ContentSHA256 != sha {
return nil, ErrIdempotencyConflict
}
}
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 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
}
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 _, joined := uow.ScopeFrom(ctx); !joined && s.Notify && s.OnOutbox != nil {
// document.agent_version_committed is owned by events.EventSink (U6).
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
}
// --- 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)
}
}
delete(s.projections, docID)
}
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
}
// NarrativeFetchSpec is the immutable input the PI narrator may see.
type NarrativeFetchSpec struct {
FromSHA string
ToSHA string
RuleRisk string
InputDigest string
Impacts []DocImpact
Hunks []DiffHunk
}
// 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
}
}