package traceimpact import ( "context" "crypto/sha256" "encoding/hex" "fmt" "strings" ) // StageReopener applies an approved reopen to the workflow instance. // It must not pause the instance or rewrite downstream stages. type StageReopener interface { ReopenCompletedStage(ctx context.Context, instanceID int64, stageKey string, docTaskKeys []string, operatorID int64) error } // DocUnlocker moves selected approved documents back to drafting. type DocUnlocker interface { ReopenApprovedDocs(ctx context.Context, projectID int64, docTaskKeys []string, actorID int64, reason string) error } // Service is the project trace index and the stage-backtrack ledger. type Service struct { store Store reopen StageReopener unlock DocUnlocker onRecheck func(ctx context.Context, mark RecheckMark) } func NewService(store Store) *Service { if store == nil { store = newMemStore() } return &Service{store: store} } func NewMemoryService() *Service { return NewService(newMemStore()) } func (s *Service) SetStageReopener(r StageReopener) { s.reopen = r } func (s *Service) SetDocUnlocker(u DocUnlocker) { s.unlock = u } // SetRecheckHook is called once when a new open recheck mark is stored. // Repeat analyzes of the same source do not call it again. func (s *Service) SetRecheckHook(fn func(ctx context.Context, mark RecheckMark)) { if s != nil { s.onRecheck = fn } } type actorCtxKey struct{} // WithActor records who triggered this analysis so a recheck notice can reach their chat. func WithActor(ctx context.Context, userID int64) context.Context { if userID <= 0 { return ctx } return context.WithValue(ctx, actorCtxKey{}, userID) } // ActorID is the user from WithActor, or 0. func ActorID(ctx context.Context) int64 { id, _ := ctx.Value(actorCtxKey{}).(int64) return id } // IndexDocument projects explicit keys into items and same-line pairs into confirmed edges. // Untyped IDs become candidate items only. func (s *Service) IndexDocument(ctx context.Context, projectID int64, doc DocRef) (IndexState, error) { if projectID <= 0 || doc.DocID == "" { return IndexState{}, fmt.Errorf("traceimpact: project and doc are required") } keys := ExtractKeys(doc.Markdown) itemByKey := map[string]Item{} for _, k := range keys { review := ReviewConfirmed if !k.Explicit { review = ReviewCandidate } item, err := s.store.UpsertItem(ctx, Item{ ProjectID: projectID, BusinessKey: k.Key, Kind: k.Kind, ReviewState: review, Statement: strings.TrimSpace(k.Line), SourceDocID: doc.DocID, }) if err != nil { st := IndexState{ProjectID: projectID, DocID: doc.DocID, SnapshotID: doc.SnapshotID, Status: ImpactFailed, Error: err.Error()} _ = s.store.PutIndex(ctx, st) return st, err } sum := sha256.Sum256([]byte(k.Key + "\n" + strings.TrimSpace(k.Line))) if err := s.store.AppendRevision(ctx, ItemRevision{ ItemID: item.ID, SourceDocID: doc.DocID, SnapshotID: doc.SnapshotID, ContentHash: hex.EncodeToString(sum[:]), Statement: strings.TrimSpace(k.Line), Extractor: ExtractorVersion, }); err != nil { return IndexState{}, err } itemByKey[k.Key] = item } for _, pair := range ExplicitPairsOnLine(doc.Markdown) { from, okA := itemByKey[pair[0]] to, okB := itemByKey[pair[1]] if !okA || !okB { continue } if _, err := s.store.UpsertEdge(ctx, Edge{ ProjectID: projectID, FromItemID: from.ID, ToItemID: to.ID, RelationType: RelationRefines, Provenance: ProvenanceExplicit, ReviewState: ReviewConfirmed, EvidenceDoc: doc.DocID, FromKey: from.BusinessKey, ToKey: to.BusinessKey, }); err != nil { return IndexState{}, err } } st := IndexState{ ProjectID: projectID, DocID: doc.DocID, SnapshotID: doc.SnapshotID, Status: "complete", Extractor: ExtractorVersion, ItemCount: len(keys), } if err := s.store.PutIndex(ctx, st); err != nil { return IndexState{}, err } return st, nil } // CoverageForDoc is what a change report should show before claiming no impact. func (s *Service) CoverageForDoc(ctx context.Context, projectID int64, docID string) string { st, ok, err := s.store.IndexStatus(ctx, projectID, docID) if err != nil || !ok || st.Status == "" { return ImpactIndexPending } switch st.Status { case "complete": return ImpactNoKnown case "failed": return ImpactFailed case "partial": return ImpactPartial default: return ImpactIndexPending } } // Analyze walks confirmed edges from keys mentioned in the changed markdown. // Missing index coverage never becomes "no impact". func (s *Service) Analyze(ctx context.Context, projectID int64, source DocRef, others []DocRef) (*Report, error) { rep := &Report{ProjectID: projectID, SourceDocID: source.DocID, Impacts: []Impact{}} if projectID <= 0 || source.DocID == "" { rep.CoverageStatus = ImpactFailed rep.Summary = SummaryForCoverage(rep.CoverageStatus) rep.Suggestions = Suggest(rep.CoverageStatus, false) return rep, nil } srcIndex, ok, err := s.store.IndexStatus(ctx, projectID, source.DocID) if err != nil { rep.CoverageStatus = ImpactFailed rep.Summary = SummaryForCoverage(rep.CoverageStatus) rep.Suggestions = Suggest(rep.CoverageStatus, false) return rep, nil } if !ok || srcIndex.Status != "complete" { rep.CoverageStatus = ImpactIndexPending if ok && srcIndex.Status == "failed" { rep.CoverageStatus = ImpactFailed } rep.Summary = SummaryForCoverage(rep.CoverageStatus) rep.Suggestions = Suggest(rep.CoverageStatus, false) return rep, nil } items, err := s.store.ListItems(ctx, projectID) if err != nil { rep.CoverageStatus = ImpactFailed rep.Summary = SummaryForCoverage(rep.CoverageStatus) rep.Suggestions = Suggest(rep.CoverageStatus, false) return rep, nil } byID := map[int64]Item{} byKey := map[string]Item{} for _, it := range items { byID[it.ID] = it byKey[it.BusinessKey] = it } var start []int64 for _, k := range ExtractKeys(source.Markdown) { if it, ok := byKey[k.Key]; ok && it.ReviewState == ReviewConfirmed { start = append(start, it.ID) } } edges, err := s.store.ListEdges(ctx, projectID) if err != nil { rep.CoverageStatus = ImpactFailed rep.Summary = SummaryForCoverage(rep.CoverageStatus) rep.Suggestions = Suggest(rep.CoverageStatus, false) return rep, nil } hits, truncated := WalkReverse(edges, start, MaxWalkDepth, MaxWalkNodes) docByKey := map[string]DocRef{source.DocID: source} for _, d := range others { docByKey[d.DocID] = d } for _, hit := range hits { it := byID[hit.ItemID] if !it.Tombstone && it.SourceDocID != "" && it.SourceDocID != source.DocID { target := docByKey[it.SourceDocID] if target.DocID != "" && !target.Visible { continue } rep.Impacts = append(rep.Impacts, Impact{ DocID: it.SourceDocID, ItemKey: it.BusinessKey, StageKey: target.StageKey, ImpactType: ImpactConfirmed, Depth: hit.Depth, Reason: fmt.Sprintf("%s 经已确认关系受本次修改影响", it.BusinessKey), }) if target.StageStatus == "completed" && target.StageKey != "" { already := false if existing, listErr := s.store.ListOpenMarks(ctx, projectID, target.StageKey); listErr == nil { for _, m := range existing { if m.SourceDoc == source.DocID { already = true break } } } saved, putErr := s.store.PutMark(ctx, RecheckMark{ ProjectID: projectID, StageKey: target.StageKey, SourceDoc: source.DocID, Status: MarkOpen, Reason: "上游或同项目条目已变化", }) if putErr == nil && !already && s.onRecheck != nil { s.onRecheck(ctx, saved) } } } } for _, e := range edges { if e.ReviewState != ReviewCandidate { continue } for _, id := range start { if e.ToItemID == id || e.FromItemID == id { rep.Impacts = append(rep.Impacts, Impact{ ItemKey: byID[e.FromItemID].BusinessKey, ImpactType: ImpactPossible, Depth: 1, Reason: "候选关系尚未确认", }) break } } } switch { case truncated: rep.CoverageStatus = ImpactPartial case len(rep.Impacts) == 0: rep.CoverageStatus = ImpactNoKnown default: rep.CoverageStatus = ImpactConfirmed for _, imp := range rep.Impacts { if imp.ImpactType == ImpactPossible && rep.CoverageStatus != ImpactConfirmed { rep.CoverageStatus = ImpactPossible } if imp.ImpactType == ImpactConfirmed { rep.CoverageStatus = ImpactConfirmed break } } onlyPossible := true for _, imp := range rep.Impacts { if imp.ImpactType == ImpactConfirmed { onlyPossible = false } } if onlyPossible { rep.CoverageStatus = ImpactPossible } } rep.Summary = SummaryForCoverage(rep.CoverageStatus) if rep.CoverageStatus == ImpactConfirmed { rep.Summary = fmt.Sprintf("发现 %d 条影响", len(rep.Impacts)) } rep.Suggestions = Suggest(rep.CoverageStatus, len(rep.Impacts) > 0) return rep, nil } // DecideCandidate confirms or rejects a model/candidate edge. func (s *Service) DecideCandidate(ctx context.Context, edgeID, actorID int64, creator bool, downstreamDoc string, downstreamAuthor int64, decision, reason string) error { if err := CanDecide(Actor{UserID: actorID, Creator: creator}, downstreamDoc, downstreamAuthor); err != nil { return err } if decision != DecisionConfirm && decision != DecisionReject { return fmt.Errorf("traceimpact: decision must be confirm or reject") } edge, err := s.store.GetEdge(ctx, edgeID) if err != nil { return err } review := ReviewConfirmed if decision == DecisionReject { review = ReviewRejected } if err := s.store.SetEdgeReview(ctx, edgeID, review); err != nil { return err } _, err = s.store.AddDecision(ctx, Decision{ ProjectID: edge.ProjectID, EdgeID: edgeID, Decision: decision, Reason: reason, DecidedBy: actorID, }) return err } // UpsertModelCandidate stores a model_candidate edge that stays out of confirmed walks until decided. func (s *Service) UpsertModelCandidate(ctx context.Context, projectID int64, fromKey, toKey, evidenceDoc string) (Edge, error) { if projectID <= 0 || fromKey == "" || toKey == "" { return Edge{}, fmt.Errorf("traceimpact: project and both keys are required") } items, err := s.store.ListItems(ctx, projectID) if err != nil { return Edge{}, err } var from, to Item for _, it := range items { if it.BusinessKey == fromKey { from = it } if it.BusinessKey == toKey { to = it } } if from.ID == 0 || to.ID == 0 { return Edge{}, fmt.Errorf("traceimpact: both items must already be indexed") } return s.store.UpsertEdge(ctx, Edge{ ProjectID: projectID, FromItemID: from.ID, ToItemID: to.ID, RelationType: RelationDependsOn, Provenance: ProvenanceModel, ReviewState: ReviewCandidate, EvidenceDoc: evidenceDoc, FromKey: from.BusinessKey, ToKey: to.BusinessKey, }) } // ListCandidateEdges returns undecided candidate edges with business keys filled in. func (s *Service) ListCandidateEdges(ctx context.Context, projectID int64) ([]Edge, error) { edges, err := s.store.ListEdges(ctx, projectID) if err != nil { return nil, err } items, err := s.store.ListItems(ctx, projectID) if err != nil { return nil, err } byID := map[int64]Item{} for _, it := range items { byID[it.ID] = it } var out []Edge for _, e := range edges { if e.ReviewState != ReviewCandidate { continue } if e.FromKey == "" { e.FromKey = byID[e.FromItemID].BusinessKey } if e.ToKey == "" { e.ToKey = byID[e.ToItemID].BusinessKey } out = append(out, e) } if out == nil { out = []Edge{} } return out, nil } // ReplayDecisions re-applies the decision ledger onto edge review_state. // Latest decision per edge wins. Repair rebuilds must call this so rejected // candidates cannot re-enter confirmed walks. func (s *Service) ReplayDecisions(ctx context.Context, projectID int64) (applied int, err error) { decisions, err := s.store.ListDecisions(ctx, projectID) if err != nil { return 0, err } latest := map[int64]Decision{} for _, d := range decisions { latest[d.EdgeID] = d } for edgeID, d := range latest { review := ReviewConfirmed if d.Decision == DecisionReject { review = ReviewRejected } if err := s.store.SetEdgeReview(ctx, edgeID, review); err != nil { return applied, err } applied++ } return applied, nil } // ListDecisions returns the authoritative decision ledger for a project. func (s *Service) ListDecisions(ctx context.Context, projectID int64) ([]Decision, error) { out, err := s.store.ListDecisions(ctx, projectID) if err == nil && out == nil { out = []Decision{} } return out, err } // NeedsRecheckAck is true when the stage still has an open mark. // Acknowledgement does not clear the mark. func (s *Service) NeedsRecheckAck(ctx context.Context, projectID int64, stageKey string) (bool, error) { marks, err := s.store.ListOpenMarks(ctx, projectID, stageKey) if err != nil { return false, err } return len(marks) > 0, nil } // AcknowledgeRecheck records that the creator saw the marks. Marks stay open. func (s *Service) AcknowledgeRecheck(ctx context.Context, projectID int64, stageKey string, userID int64) error { marks, err := s.store.ListOpenMarks(ctx, projectID, stageKey) if err != nil { return err } for _, m := range marks { if err := s.store.AckMark(ctx, m.ID, userID); err != nil { return err } } return nil } // ClearRecheck closes a mark only for an explicit human reason. func (s *Service) ClearRecheck(ctx context.Context, markID int64, reason string) error { switch reason { case ClearNoChange, ClearRevision: default: return fmt.Errorf("traceimpact: recheck marks close only by no_change or revision_started") } return s.store.SetMarkStatus(ctx, markID, MarkCleared, reason) } // ReopenMark opens a previously cleared mark again, for example after a rejected proposal. func (s *Service) ReopenMark(ctx context.Context, markID int64, reason string) error { return s.store.SetMarkStatus(ctx, markID, MarkOpen, reason) } // RequestReopen stores a request. Creators are approved immediately and applied. func (s *Service) RequestReopen(ctx context.Context, req ReopenRequest, creator bool) (ReopenRequest, error) { if req.StageKey == "" || len(req.DocTasks) == 0 || strings.TrimSpace(req.Reason) == "" { return ReopenRequest{}, fmt.Errorf("traceimpact: stage, documents, and reason are required") } req.Status = ReopenPending stored, err := s.store.PutReopen(ctx, req) if err != nil { return ReopenRequest{}, err } if creator { return s.ApproveReopen(ctx, stored.ID, req.RequestedBy) } return stored, nil } // ApproveReopen lets the creator apply a pending request. func (s *Service) ApproveReopen(ctx context.Context, requestID, creatorID int64) (ReopenRequest, error) { req, err := s.store.GetReopen(ctx, requestID) if err != nil { return ReopenRequest{}, err } if req.Status == ReopenApproved { return req, nil } if s.reopen == nil { return ReopenRequest{}, fmt.Errorf("traceimpact: stage reopen is not configured") } if err := s.reopen.ReopenCompletedStage(ctx, req.InstanceID, req.StageKey, req.DocTasks, creatorID); err != nil { return ReopenRequest{}, err } if s.unlock != nil { if err := s.unlock.ReopenApprovedDocs(ctx, req.ProjectID, req.DocTasks, creatorID, req.Reason); err != nil { return ReopenRequest{}, err } } req.Status = ReopenApproved return s.store.PutReopen(ctx, req) } func (s *Service) ListItems(ctx context.Context, projectID int64) ([]Item, error) { out, err := s.store.ListItems(ctx, projectID) if err == nil && out == nil { out = []Item{} } return out, err } func (s *Service) ListIndex(ctx context.Context, projectID int64) ([]IndexState, error) { out, err := s.store.ListIndex(ctx, projectID) if err == nil && out == nil { out = []IndexState{} } return out, err } func (s *Service) ListOpenMarks(ctx context.Context, projectID int64, stageKey string) ([]RecheckMark, error) { out, err := s.store.ListOpenMarks(ctx, projectID, stageKey) if err == nil && out == nil { out = []RecheckMark{} } return out, err } func (s *Service) GetReopen(ctx context.Context, id int64) (ReopenRequest, error) { return s.store.GetReopen(ctx, id) } func (s *Service) ListPendingReopens(ctx context.Context, projectID int64) ([]ReopenRequest, error) { if s == nil || s.store == nil { return nil, nil } out, err := s.store.ListPendingReopens(ctx, projectID) if err == nil && out == nil { out = []ReopenRequest{} } return out, err } // ErrNeedsConfirm is returned when a revision proposal is requested before confirmation. var ErrNeedsConfirm = fmt.Errorf("traceimpact: confirmation required") // ProposeRevision records a proposal for a human author. It never edits controlled content. func (s *Service) ProposeRevision(ctx context.Context, projectID, userID int64, text string, confirmed bool) (string, error) { if !confirmed { return "", ErrNeedsConfirm } if projectID <= 0 || strings.TrimSpace(text) == "" { return "", fmt.Errorf("traceimpact: project and proposal text are required") } id, err := s.store.AddProposal(ctx, projectID, userID, strings.TrimSpace(text)) if err != nil { return "", err } return fmt.Sprintf("proposal-%d-by-%d", id, userID), nil }