Files
WeiCHEN 9da7f352fa 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.
2026-09-23 09:52:49 +08:00

196 lines
5.9 KiB
Go

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
}