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>
1126 lines
36 KiB
Go
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
|
|
}
|
|
}
|