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:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user