diff --git a/internal/docrevision/service.go b/internal/docrevision/service.go index d9f8d2f5..e34a7b20 100644 --- a/internal/docrevision/service.go +++ b/internal/docrevision/service.go @@ -2,15 +2,9 @@ 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" ) @@ -115,1055 +109,6 @@ func requireDocID(docID string) error { 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) - 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 -} - -// 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 -} - -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 @@ -1173,12 +118,3 @@ type NarrativeFetchSpec struct { 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 - } -} diff --git a/internal/docrevision/service_agent.go b/internal/docrevision/service_agent.go new file mode 100644 index 00000000..64939646 --- /dev/null +++ b/internal/docrevision/service_agent.go @@ -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 +} diff --git a/internal/docrevision/service_compat.go b/internal/docrevision/service_compat.go new file mode 100644 index 00000000..e116da00 --- /dev/null +++ b/internal/docrevision/service_compat.go @@ -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 + } +} diff --git a/internal/docrevision/service_human.go b/internal/docrevision/service_human.go new file mode 100644 index 00000000..b1705359 --- /dev/null +++ b/internal/docrevision/service_human.go @@ -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 +} diff --git a/internal/docrevision/service_locked.go b/internal/docrevision/service_locked.go new file mode 100644 index 00000000..772eda1f --- /dev/null +++ b/internal/docrevision/service_locked.go @@ -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 +} diff --git a/internal/docrevision/service_read.go b/internal/docrevision/service_read.go new file mode 100644 index 00000000..980f407e --- /dev/null +++ b/internal/docrevision/service_read.go @@ -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 +}