Files
Agent-System/internal/docduty/revoke.go
T
weichenandCursor 9a19bcc12c fix(docduty): revoke inactive slots and explicit duty changes
Empty assigned heal now clears only exited or non-member reviewer/approver
holders and never blanks author. Explicit reconcile uses the pre-update
duty plus a project advisory lock so viewer demotion and transfers are
not left to generic heal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 22:29:53 +00:00

114 lines
3.3 KiB
Go

package docduty
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// MemberDutyChange 成员职责/在任状态变更,供显式撤销文档槽位。
type MemberDutyChange struct {
UserID int64
PreviousDuty string
NextDuty string
Exited bool
PriorHolderIDs map[string]int64
}
// SlotRevocation 从受控文档上撤销某用户的审核/批准槽位;永不针对 author。
type SlotRevocation struct {
UserID int64
Duty string
}
// PriorDutyHolders 更新前在任成员的职责持有者。
func PriorDutyHolders(members []Member) map[string]int64 {
out := make(map[string]int64, 3)
for _, m := range members {
if !isActiveMember(m) {
continue
}
switch m.DocumentDuty {
case DutyAuthor, DutyReviewer, DutyApprover:
out[m.DocumentDuty] = m.UserID
}
}
return out
}
// PlanSlotRevocations 按更新前 duty 显式计算要撤销的审核/批准槽位。
// reviewer/approver → viewer/其他职责、职责转移、退出成员都会产生撤销;author 永不进入结果。
func PlanSlotRevocations(change MemberDutyChange) []SlotRevocation {
seen := make(map[string]struct{}, 4)
out := make([]SlotRevocation, 0, 2)
add := func(userID int64, duty string) {
if userID <= 0 || (duty != DutyReviewer && duty != DutyApprover) {
return
}
key := fmt.Sprintf("%d:%s", userID, duty)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, SlotRevocation{UserID: userID, Duty: duty})
}
if change.Exited || change.PreviousDuty != change.NextDuty {
add(change.UserID, change.PreviousDuty)
}
if change.NextDuty == DutyReviewer || change.NextDuty == DutyApprover {
if prior := change.PriorHolderIDs[change.NextDuty]; prior > 0 && prior != change.UserID {
add(prior, change.NextDuty)
}
}
return out
}
// ReconcileMemberDuty 在项目级 advisory lock 下按更新前 duty 清空对应槽位,再 heal 新职责。
func ReconcileMemberDuty(ctx context.Context, pool *pgxpool.Pool, projectID int64, change MemberDutyChange) error {
if pool == nil || projectID <= 0 {
return nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("docduty: begin reconcile: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, projectID); err != nil {
return fmt.Errorf("docduty: lock project %d: %w", projectID, err)
}
for _, rev := range PlanSlotRevocations(change) {
q, ok := clearDutySlotSQL(rev.Duty)
if !ok {
continue
}
if _, err := tx.Exec(ctx, q, projectID, rev.UserID); err != nil {
return fmt.Errorf("docduty: clear %s for user %d: %w", rev.Duty, rev.UserID, err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("docduty: commit reconcile: %w", err)
}
_, _, err = HealProjectDocuments(ctx, pool, projectID)
return err
}
func clearDutySlotSQL(duty string) (string, bool) {
switch duty {
case DutyReviewer:
return `
UPDATE document_records
SET reviewer_id = NULL, reviewer_name = '', updated_at = now()
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND reviewer_id = $2
`, true
case DutyApprover:
return `
UPDATE document_records
SET approver_id = NULL, approver_name = '', updated_at = now()
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND approver_id = $2
`, true
default:
return "", false
}
}