refactor(docrevision): split service.go into use-case files

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