Move human/agent/read/locked/compat paths into ≤800-line siblings without behavior change; short tests green.
196 lines
5.9 KiB
Go
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
|
|
}
|