Wire intent→coordinator DocResolver confirm cards→action_executor kinds for comment, reopen, pending-supplements query, and real submit-review execution.
316 lines
10 KiB
Go
316 lines
10 KiB
Go
package supplementimpact
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/docgov"
|
|
"chat-agent-system/internal/notification"
|
|
"chat-agent-system/internal/traceimpact"
|
|
)
|
|
|
|
// DocLookup resolves document claim/lease snapshots.
|
|
type DocLookup interface {
|
|
GetDocument(ctx context.Context, docID string) (*docgov.DocumentRecord, error)
|
|
}
|
|
|
|
// Analyzer runs traceimpact analysis.
|
|
type Analyzer interface {
|
|
Analyze(ctx context.Context, projectID int64, source traceimpact.DocRef, others []traceimpact.DocRef) (*traceimpact.Report, error)
|
|
}
|
|
|
|
// Store persists batches.
|
|
type Store interface {
|
|
InsertBatch(ctx context.Context, b *Batch) error
|
|
GetBatch(ctx context.Context, batchID string) (*Batch, error)
|
|
SaveDecision(ctx context.Context, batchID string, revision int64, nonce, digest, status string, selected map[string]bool) error
|
|
UpdateItemOutcome(ctx context.Context, batchID, docID, outcome, proposalID, runID string) error
|
|
}
|
|
|
|
// Notifier emits claim-aware digests.
|
|
type Notifier interface {
|
|
UpsertDocumentDigest(ctx context.Context, p notification.UpsertDigestParams) error
|
|
}
|
|
|
|
// Proposer creates docgov change proposals for claimed docs.
|
|
type Proposer interface {
|
|
ProposeModification(ctx context.Context, docID, triggeredBy, changes, impact, risk string) (*docgov.ChangeProposal, error)
|
|
}
|
|
|
|
// PendingLedger records supplements deferred while a document is lifecycle-locked.
|
|
type PendingLedger interface {
|
|
RecordPendingSupplement(ctx context.Context, in docgov.RecordPendingSupplementInput) (*docgov.PendingSupplement, error)
|
|
}
|
|
|
|
// Service orchestrates supplement → impact → confirm → split execution.
|
|
type Service struct {
|
|
Store Store
|
|
Docs DocLookup
|
|
Analyze Analyzer
|
|
Notify Notifier
|
|
Propose Proposer
|
|
Pending PendingLedger
|
|
Now func() time.Time
|
|
}
|
|
|
|
func (s *Service) now() time.Time {
|
|
if s.Now != nil {
|
|
return s.Now()
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
// CreateFromSupplement runs impact analysis and opens a confirmation batch.
|
|
func (s *Service) GetBatch(ctx context.Context, batchID string) (*Batch, error) {
|
|
if s == nil || s.Store == nil {
|
|
return nil, fmt.Errorf("supplementimpact: not configured")
|
|
}
|
|
return s.Store.GetBatch(ctx, batchID)
|
|
}
|
|
|
|
func (s *Service) CreateFromSupplement(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string) (*Batch, error) {
|
|
if s == nil || s.Store == nil || s.Analyze == nil {
|
|
return nil, fmt.Errorf("supplementimpact: not configured")
|
|
}
|
|
report, err := s.Analyze.Analyze(ctx, projectID, traceimpact.DocRef{DocID: "supplement", Markdown: markdown}, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
coverage := "no_known_dependencies"
|
|
if report != nil && report.CoverageStatus != "" {
|
|
coverage = string(report.CoverageStatus)
|
|
}
|
|
// Never present index_pending/partial/failed as "no impact".
|
|
switch coverage {
|
|
case "index_pending", "partial", "failed":
|
|
// keep as-is for the card
|
|
default:
|
|
}
|
|
batchID := "sib_" + randomHex(8)
|
|
nonce := randomHex(16)
|
|
now := s.now()
|
|
b := &Batch{
|
|
BatchID: batchID, ProjectID: projectID, SessionID: sessionID,
|
|
InitiatorUserID: userID, SourceKind: "fact_supplement",
|
|
SourceSummary: truncate(summary, 120), CoverageStatus: coverage,
|
|
Status: StatusPending, DecisionNonce: nonce, Revision: 1,
|
|
ExpiresAt: now.Add(BatchTTL),
|
|
}
|
|
seen := map[string]struct{}{}
|
|
if report != nil {
|
|
for _, imp := range report.Impacts {
|
|
if imp.DocID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[imp.DocID]; ok {
|
|
continue
|
|
}
|
|
seen[imp.DocID] = struct{}{}
|
|
item := Item{
|
|
BatchID: batchID, DocID: imp.DocID, ItemKey: imp.ItemKey,
|
|
ImpactType: string(imp.ImpactType), Depth: imp.Depth, Reason: imp.Reason,
|
|
ChangeSummary: truncate(imp.Reason, 80),
|
|
}
|
|
if s.Docs != nil {
|
|
if doc, err := s.Docs.GetDocument(ctx, imp.DocID); err == nil && doc != nil {
|
|
item.Title = doc.Title
|
|
item.AuthorClaimedSnapshot = doc.AuthorClaimed
|
|
item.AuthorIDSnapshot = doc.AuthorID
|
|
item.ReviewerIDSnapshot = doc.ReviewerID
|
|
item.ApproverIDSnapshot = doc.ApproverID
|
|
item.CurrentStateSnapshot = string(doc.CurrentState)
|
|
item.BusySnapshot = doc.HasActiveLease(now)
|
|
item.LockedSnapshot = docIsLifecycleLocked(doc)
|
|
}
|
|
}
|
|
b.Items = append(b.Items, item)
|
|
}
|
|
}
|
|
if err := s.Store.InsertBatch(ctx, b); err != nil {
|
|
return nil, err
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func docIsLifecycleLocked(doc *docgov.DocumentRecord) bool {
|
|
if doc == nil {
|
|
return false
|
|
}
|
|
if doc.IsLocked {
|
|
return true
|
|
}
|
|
return doc.CurrentState != "" && doc.CurrentState != docgov.StateDrafting
|
|
}
|
|
|
|
// Decide confirms or cancels a batch; on confirm, executes selected items.
|
|
func (s *Service) Decide(ctx context.Context, cmd DecisionCommand) (*Batch, error) {
|
|
b, err := s.Store.GetBatch(ctx, cmd.BatchID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if b.Status != StatusPending {
|
|
return nil, fmt.Errorf("supplementimpact: batch not pending")
|
|
}
|
|
if s.now().After(b.ExpiresAt) {
|
|
_ = s.Store.SaveDecision(ctx, b.BatchID, b.Revision, cmd.DecisionNonce, cmd.DecisionDigest, StatusExpired, nil)
|
|
return nil, fmt.Errorf("supplementimpact: batch expired")
|
|
}
|
|
if cmd.ExpectedRevision != b.Revision {
|
|
return nil, fmt.Errorf("supplementimpact: revision conflict")
|
|
}
|
|
if cmd.ActorUserID != b.InitiatorUserID {
|
|
return nil, fmt.Errorf("supplementimpact: only initiator may decide")
|
|
}
|
|
if cmd.Kind == "cancel" {
|
|
if err := s.Store.SaveDecision(ctx, b.BatchID, b.Revision, cmd.DecisionNonce, cmd.DecisionDigest, StatusCancelled, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Store.GetBatch(ctx, cmd.BatchID)
|
|
}
|
|
selected := map[string]bool{}
|
|
for _, id := range cmd.SelectedDocIDs {
|
|
selected[id] = true
|
|
}
|
|
if err := s.Store.SaveDecision(ctx, b.BatchID, b.Revision, cmd.DecisionNonce, cmd.DecisionDigest, StatusRunning, selected); err != nil {
|
|
return nil, err
|
|
}
|
|
b, _ = s.Store.GetBatch(ctx, cmd.BatchID)
|
|
for i := range b.Items {
|
|
it := &b.Items[i]
|
|
if !selected[it.DocID] {
|
|
continue
|
|
}
|
|
if it.LockedSnapshot {
|
|
_ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeSkippedLocked, "", "")
|
|
s.recordPendingLocked(ctx, b, it)
|
|
s.notifyLockedDutyHolder(ctx, b, it)
|
|
continue
|
|
}
|
|
if it.BusySnapshot {
|
|
_ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeSkippedBusy, "", "")
|
|
continue
|
|
}
|
|
if err := s.executeItem(ctx, b, it); err != nil {
|
|
_ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeFailed, "", "")
|
|
}
|
|
}
|
|
final := StatusDone
|
|
b, _ = s.Store.GetBatch(ctx, cmd.BatchID)
|
|
for _, it := range b.Items {
|
|
if !it.Selected {
|
|
continue
|
|
}
|
|
switch it.Outcome {
|
|
case OutcomeSkippedBusy, OutcomeSkippedLocked, OutcomeFailed, OutcomeProposalPending:
|
|
final = StatusPartiallyDone
|
|
}
|
|
}
|
|
_ = s.Store.SaveDecision(ctx, b.BatchID, b.Revision+1, cmd.DecisionNonce, cmd.DecisionDigest, final, selected)
|
|
return s.Store.GetBatch(ctx, cmd.BatchID)
|
|
}
|
|
|
|
func (s *Service) recordPendingLocked(ctx context.Context, b *Batch, it *Item) {
|
|
if s == nil || s.Pending == nil || b == nil || it == nil {
|
|
return
|
|
}
|
|
_, _ = s.Pending.RecordPendingSupplement(ctx, docgov.RecordPendingSupplementInput{
|
|
DocID: it.DocID,
|
|
ProjectID: b.ProjectID,
|
|
InitiatorUserID: b.InitiatorUserID,
|
|
SourceSummary: b.SourceSummary,
|
|
ImpactReason: it.Reason,
|
|
BatchID: b.BatchID,
|
|
})
|
|
}
|
|
|
|
func dutyHolderForLockedItem(it *Item) int64 {
|
|
if it == nil {
|
|
return 0
|
|
}
|
|
switch it.CurrentStateSnapshot {
|
|
case string(docgov.StateReviewing):
|
|
if it.ReviewerIDSnapshot > 0 {
|
|
return it.ReviewerIDSnapshot
|
|
}
|
|
case string(docgov.StateApproving), string(docgov.StateApproved):
|
|
if it.ApproverIDSnapshot > 0 {
|
|
return it.ApproverIDSnapshot
|
|
}
|
|
}
|
|
if it.AuthorIDSnapshot > 0 {
|
|
return it.AuthorIDSnapshot
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (s *Service) notifyLockedDutyHolder(ctx context.Context, b *Batch, it *Item) {
|
|
if s == nil || s.Notify == nil || b == nil || it == nil {
|
|
return
|
|
}
|
|
userID := dutyHolderForLockedItem(it)
|
|
if userID <= 0 {
|
|
return
|
|
}
|
|
_ = s.Notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
|
|
UserID: userID, ProjectID: b.ProjectID,
|
|
DigestKind: notification.DigestKindPendingSupplements, DocID: it.DocID, DocTitle: it.Title,
|
|
Severity: notification.SeverityInfo,
|
|
ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID),
|
|
Summary: b.SourceSummary,
|
|
})
|
|
}
|
|
|
|
func (s *Service) executeItem(ctx context.Context, b *Batch, it *Item) error {
|
|
if it.AuthorClaimedSnapshot {
|
|
if s.Propose == nil {
|
|
return fmt.Errorf("proposer not configured")
|
|
}
|
|
prop, err := s.Propose.ProposeModification(ctx, it.DocID, fmt.Sprintf("user:%d", b.InitiatorUserID),
|
|
it.ChangeSummary, it.Reason, "补充材料触发的已认领文档变更,需编制负责人确认")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeProposalPending, prop.ProposalID, "")
|
|
if s.Notify != nil && it.AuthorIDSnapshot > 0 {
|
|
_ = s.Notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
|
|
UserID: it.AuthorIDSnapshot, ProjectID: b.ProjectID,
|
|
DigestKind: notification.DigestKindProposalRequired, DocID: it.DocID, DocTitle: it.Title,
|
|
Severity: notification.SeverityActionRequired,
|
|
ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID),
|
|
Summary: b.SourceSummary, ProposalID: prop.ProposalID,
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
// Unclaimed: notify info-only; actual content rewrite is owned by squad/task path.
|
|
_ = s.Store.UpdateItemOutcome(ctx, b.BatchID, it.DocID, OutcomeApplied, "", "")
|
|
if s.Notify != nil && it.AuthorIDSnapshot > 0 {
|
|
_ = s.Notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
|
|
UserID: it.AuthorIDSnapshot, ProjectID: b.ProjectID,
|
|
DigestKind: notification.DigestKindUnclaimedEdit, DocID: it.DocID, DocTitle: it.Title,
|
|
Severity: notification.SeverityInfo,
|
|
ActorID: b.InitiatorUserID, ActorName: fmt.Sprintf("user:%d", b.InitiatorUserID),
|
|
Summary: b.SourceSummary,
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func randomHex(n int) string {
|
|
b := make([]byte, n)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
r := []rune(strings.TrimSpace(s))
|
|
if len(r) <= n {
|
|
return string(r)
|
|
}
|
|
return string(r[:n]) + "…"
|
|
}
|