merge: feature/docgov-vocabulary-2026-09-22

Keep contracts/vocabulary with G1 uow/workspace duty ports; chat stays decoupled.
This commit is contained in:
WeiCHEN
2026-09-22 21:58:16 +08:00
18 changed files with 335 additions and 150 deletions
+4 -3
View File
@@ -4,13 +4,14 @@ import (
"context"
"encoding/json"
"github.com/jackc/pgx/v5"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/events"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"github.com/jackc/pgx/v5"
)
// EventConsumer applies document.approved onto workflow task bookkeeping.
@@ -67,7 +68,7 @@ func (c *EventConsumer) HandleLease(ctx context.Context, lease events.Lease, tx
if doc.ProjectID != lease.ProjectID {
return events.ErrQuarantine
}
if doc.CurrentState != docgov.StateApproved {
if doc.CurrentState != vocabulary.StateApproved {
return events.ErrQuarantine
}
if taskKey == "" {
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"time"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/events"
@@ -24,7 +25,7 @@ const (
type Principal struct {
UserID int64
Name string
Role docgov.UserRole
Role vocabulary.UserRole
}
type HumanEditCommand struct {
@@ -42,7 +43,7 @@ type DecisionCommand struct {
DocID string
Comment string
RejectReason string
RollbackTo docgov.DocumentState
RollbackTo vocabulary.DocumentState
}
type Commands struct {
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"time"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/taskrun"
@@ -70,7 +71,7 @@ func (a *docTaskBusyCheckerAdapter) DocTaskSquadBusy(ctx context.Context, projec
}
doc, err := a.gov.FindByProjectTaskKey(ctx, projectID, taskKey)
if err != nil {
if err == docgov.ErrDocumentNotFound {
if err == vocabulary.ErrDocumentNotFound {
return false, "", nil
}
return false, "", err
@@ -78,7 +79,7 @@ func (a *docTaskBusyCheckerAdapter) DocTaskSquadBusy(ctx context.Context, projec
if doc == nil || !doc.HasActiveLease(time.Now()) {
return false, "", nil
}
if doc.LeaseHolderType == docgov.LeaseHolderSquad && a.taskRuns != nil {
if doc.LeaseHolderType == vocabulary.LeaseHolderSquad && a.taskRuns != nil {
if run, runErr := a.taskRuns.GetRun(doc.LeaseHolderID); runErr == nil && run.Status != "running" {
return false, "", nil
}
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"strings"
"chat-agent-system/internal/app/docnotify"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
@@ -49,10 +50,10 @@ func (t traceUnlock) ReopenApprovedDocs(ctx context.Context, projectID int64, do
for _, key := range docTaskKeys {
docID := fmt.Sprintf("doc_proj%d_%s", projectID, key)
_, err := t.gov.ReopenForRevision(ctx, docID, actorID, "creator", reason)
if err == nil || errors.Is(err, docgov.ErrDocumentNotFound) {
if err == nil || errors.Is(err, vocabulary.ErrDocumentNotFound) {
continue
}
if errors.Is(err, docgov.ErrInvalidStateTransition) {
if errors.Is(err, vocabulary.ErrInvalidStateTransition) {
continue
}
return err
+11 -10
View File
@@ -17,6 +17,7 @@ import (
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/config"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/db"
"chat-agent-system/internal/docduty"
@@ -541,7 +542,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
}
upstreamDocID := fmt.Sprintf("doc_proj%d_system_reqs", req.ProjectID)
if s.docGov != nil && req.ProjectID > 0 {
if doc, err := s.docGov.GetDocument(ctx, upstreamDocID); err == nil && doc != nil && doc.CurrentState == docgov.StateApproved {
if doc, err := s.docGov.GetDocument(ctx, upstreamDocID); err == nil && doc != nil && doc.CurrentState == vocabulary.StateApproved {
upstreamSRDoc = doc
if doc.WorkDir != "" {
contentPath := filepath.Join(doc.WorkDir, "output.md")
@@ -594,7 +595,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
continue
}
doc, err := s.docGov.GetDocument(ctx, docID)
if err != nil || doc == nil || doc.CurrentState != docgov.StateApproved || doc.WorkDir == "" {
if err != nil || doc == nil || doc.CurrentState != vocabulary.StateApproved || doc.WorkDir == "" {
continue
}
if b, readErr := os.ReadFile(filepath.Join(doc.WorkDir, "output.md")); readErr == nil && len(b) > 0 {
@@ -720,7 +721,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
Success: false,
ErrorReason: fmt.Sprintf("文档处于【%s】阶段已锁定,禁止 force_direct_edit", docRec.CurrentState),
DocRecord: docRec,
}, docgov.ErrDocumentLocked
}, vocabulary.ErrDocumentLocked
}
// 已认领文档必须有编制人已同意的提案;未认领可直接强制覆写
if docRec != nil && docRec.AuthorClaimed {
@@ -733,14 +734,14 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
Success: false,
ErrorReason: "force_direct_edit 需要编制人已同意的变更提案 (approved_by_author)",
DocRecord: docRec,
}, docgov.ErrForceEditNotAllowed
}, vocabulary.ErrForceEditNotAllowed
}
appliedProposalID = prop.ProposalID
}
} else if docRec != nil {
checkRes, checkErr := s.docGov.CheckModificationPermission(ctx, docID, docgov.RoleSystem)
checkRes, checkErr := s.docGov.CheckModificationPermission(ctx, docID, vocabulary.RoleSystem)
if checkErr != nil {
if errors.Is(checkErr, docgov.ErrDocumentLocked) || errors.Is(checkErr, docgov.ErrDocumentBusy) {
if errors.Is(checkErr, vocabulary.ErrDocumentLocked) || errors.Is(checkErr, vocabulary.ErrDocumentBusy) {
reason := checkErr.Error()
if checkRes != nil && checkRes.Reason != "" {
reason = checkRes.Reason
@@ -810,11 +811,11 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
leaseTTL = s.cfg.AgentTimeout
}
if leaseTTL <= 0 {
leaseTTL = docgov.DefaultSquadLeaseTTL
leaseTTL = vocabulary.DefaultSquadLeaseTTL
}
leased, leaseErr := s.docGov.AcquireLease(ctx, docgov.LeaseAcquireRequest{
DocID: docID,
HolderType: docgov.LeaseHolderSquad,
HolderType: vocabulary.LeaseHolderSquad,
HolderID: leaseHolderID,
WorkDir: req.WorkDir,
TTL: leaseTTL,
@@ -860,7 +861,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
return
case <-ticker.C:
hbCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_, _ = s.docGov.HeartbeatLease(hbCtx, docID, string(docgov.LeaseHolderSquad), leaseHolderID, leaseTTL)
_, _ = s.docGov.HeartbeatLease(hbCtx, docID, string(vocabulary.LeaseHolderSquad), leaseHolderID, leaseTTL)
cancel()
}
}
@@ -868,7 +869,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
defer func() {
if leaseAcquired {
relCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_, _ = s.docGov.ReleaseLease(relCtx, docID, string(docgov.LeaseHolderSquad), leaseHolderID)
_, _ = s.docGov.ReleaseLease(relCtx, docID, string(vocabulary.LeaseHolderSquad), leaseHolderID)
cancel()
if streamer := stream.FromContext(ctx); streamer != nil {
streamer.EmitGovernance(stream.GovernancePayload{
+6 -5
View File
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/taskrun"
@@ -58,7 +59,7 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending
if s.Actions.DocGov == nil {
return nil, fmt.Errorf("docgov engine not available")
}
doc, err := s.Actions.DocGov.ReviewApprove(ctx, action.DocID, uid, docgov.RoleReviewer, name, action.Comment)
doc, err := s.Actions.DocGov.ReviewApprove(ctx, action.DocID, uid, vocabulary.RoleReviewer, name, action.Comment)
if err != nil {
return nil, err
}
@@ -68,7 +69,7 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending
if s.Actions.DocGov == nil {
return nil, fmt.Errorf("docgov engine not available")
}
doc, err := s.Actions.DocGov.ApproverApprove(ctx, action.DocID, uid, docgov.RoleApprover, name, action.Comment)
doc, err := s.Actions.DocGov.ApproverApprove(ctx, action.DocID, uid, vocabulary.RoleApprover, name, action.Comment)
if err != nil {
return nil, err
}
@@ -82,13 +83,13 @@ func (s *Session) executeAction(ctx context.Context, action *coordinator.Pending
if reason == "" {
reason = "用户确认驳回"
}
doc, err := s.Actions.DocGov.ReviewReject(ctx, action.DocID, uid, docgov.RoleReviewer, name, reason)
doc, err := s.Actions.DocGov.ReviewReject(ctx, action.DocID, uid, vocabulary.RoleReviewer, name, reason)
if err != nil {
rollback := action.Rollback
if rollback == "" {
rollback = docgov.StateDrafting
rollback = vocabulary.StateDrafting
}
doc, err = s.Actions.DocGov.ApproverReject(ctx, action.DocID, uid, docgov.RoleApprover, name, reason, rollback)
doc, err = s.Actions.DocGov.ApproverReject(ctx, action.DocID, uid, vocabulary.RoleApprover, name, reason, rollback)
if err != nil {
return nil, err
}
@@ -0,0 +1,87 @@
// Package vocabulary holds stable, behavior-free shared enums and sentinel values.
// Domain packages keep invariants and typed error wrappers; consumers import this
// leaf instead of pulling docgov solely for state/role/error strings.
package vocabulary
import (
"errors"
"time"
)
// DocumentState is the controlled-document lifecycle vocabulary.
type DocumentState string
const (
StateDrafting DocumentState = "drafting" // 编制中 (草稿)
StateReviewing DocumentState = "reviewing" // 审核中 (待审核, 锁定)
StateApproving DocumentState = "approving" // 批准中 (待批准, 锁定)
StateApproved DocumentState = "approved" // 已批准 (归档生效, 终态锁定)
StateRejected DocumentState = "rejected" // 已驳回 (中间状态或流转标记)
)
// UserRole is the document-governance duty role vocabulary.
type UserRole string
const (
RoleAuthor UserRole = "author" // 编制人
RoleReviewer UserRole = "reviewer" // 审核人
RoleApprover UserRole = "approver" // 批准人
RoleSystem UserRole = "system" // 系统/智能体小队
)
// LeaseHolderType is the edit-lease holder vocabulary (orthogonal to IsLocked).
type LeaseHolderType string
const (
LeaseHolderNone LeaseHolderType = ""
LeaseHolderSquad LeaseHolderType = "squad"
LeaseHolderHuman LeaseHolderType = "human"
)
// Default lease TTLs shared by squad/human writers.
const (
DefaultSquadLeaseTTL = 30 * time.Minute
DefaultHumanLeaseTTL = 15 * time.Minute
)
// CommentType is the document comment-kind vocabulary.
type CommentType string
const (
CommentGeneral CommentType = "comment" // 普通批注
CommentSuggestion CommentType = "suggestion" // 修改建议
CommentRejection CommentType = "reject_reason" // 驳回原因
)
// Cross-module sentinel errors. Wire/HTTP mapping stays in server; wrappers with
// extra fields (e.g. DocumentBusyError) remain in the owning domain package.
var (
ErrDocumentNotFound = errors.New("docgov: document not found")
ErrDocumentLocked = errors.New("docgov: document is locked, direct modification prohibited")
ErrOnlyAuthorCanEdit = errors.New("docgov: only the designated author is allowed to edit or modify document content")
ErrReviewerCannotEdit = errors.New("docgov: reviewers are not allowed to edit document content, only comments and review decisions are permitted")
ErrApproverCannotEdit = errors.New("docgov: approvers are not allowed to edit document content, only comments and approval decisions are permitted")
ErrInvalidStateTransition = errors.New("docgov: invalid document lifecycle state transition")
ErrRejectReasonRequired = errors.New("docgov: rejection reason must not be empty")
ErrUnauthorizedActor = errors.New("docgov: actor is not authorized for this role or action")
ErrProposalNotFound = errors.New("docgov: change proposal not found")
ErrProposalAlreadyDecided = errors.New("docgov: change proposal has already been decided")
ErrChangeProposalPending = errors.New("docgov: document is claimed and has pending change proposal waiting for author approval")
ErrDocumentBusy = errors.New("docgov: document edit lease is held by another writer")
ErrStaleFencing = errors.New("docgov: stale lease fencing token")
ErrLeaseExpired = errors.New("docgov: document edit lease has expired")
ErrLeaseNotHeld = errors.New("docgov: caller does not hold the document edit lease")
ErrForceEditNotAllowed = errors.New("docgov: force_direct_edit requires an approved_by_author proposal and unlocked drafting document")
ErrContentProjectionMissing = errors.New("docgov: document content projection missing")
ErrContentProjectionEmpty = errors.New("docgov: document content projection empty")
ErrContentIntegrityMismatch = errors.New("docgov: document content integrity mismatch")
)
// Stable machine-readable content-error codes (HTTP status mapping stays in server).
const (
ErrCodeDocumentNotFound = "DOCUMENT_NOT_FOUND"
ErrCodeProjectionMissing = "DOCUMENT_CONTENT_PROJECTION_MISSING"
ErrCodeProjectionEmpty = "DOCUMENT_CONTENT_PROJECTION_EMPTY"
ErrCodeIntegrityMismatch = "DOCUMENT_CONTENT_INTEGRITY_MISMATCH"
ErrCodeProjectionDrift = "DOCUMENT_CONTENT_PROJECTION_DRIFT"
)
+6 -6
View File
@@ -14,8 +14,8 @@ import (
"chat-agent-system/internal/agent"
"chat-agent-system/internal/app/stageflow"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/kb"
"chat-agent-system/internal/maturity"
@@ -42,7 +42,7 @@ type PendingAction struct {
WorkDir string
Reason string
Comment string
Rollback docgov.DocumentState
Rollback vocabulary.DocumentState
TaskKey string
}
@@ -1013,7 +1013,7 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.
UserID: userID,
})
if err != nil {
if errors.Is(err, docgov.ErrDocumentBusy) {
if errors.Is(err, vocabulary.ErrDocumentBusy) {
msg := "该文档正在由项目助手生成或他人编辑中,请稍后再发起更新。您可以先继续对话或处理其他文档。"
if taskRes != nil && taskRes.ErrorReason != "" {
msg = taskRes.ErrorReason + "。您可以先继续对话或处理其他文档。"
@@ -1027,7 +1027,7 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.
UpdatedContext: userCtx,
}, nil
}
if errors.Is(err, docgov.ErrDocumentLocked) {
if errors.Is(err, vocabulary.ErrDocumentLocked) {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
@@ -1654,9 +1654,9 @@ func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand
}
userCtx.applyPending(nil)
rollback := docgov.StateDrafting
rollback := vocabulary.StateDrafting
if userCtx.InteractiveCard != nil && strings.Contains(userCtx.InteractiveCard.ImpactRisk, "reviewing") {
rollback = docgov.StateDrafting
rollback = vocabulary.StateDrafting
}
return &CoordinationResult{
+58 -71
View File
@@ -1,38 +1,70 @@
package docgov
import (
"errors"
"time"
"chat-agent-system/internal/contracts/vocabulary"
)
var (
ErrDocumentNotFound = errors.New("docgov: document not found")
ErrDocumentLocked = errors.New("docgov: document is locked, direct modification prohibited")
ErrOnlyAuthorCanEdit = errors.New("docgov: only the designated author is allowed to edit or modify document content")
ErrReviewerCannotEdit = errors.New("docgov: reviewers are not allowed to edit document content, only comments and review decisions are permitted")
ErrApproverCannotEdit = errors.New("docgov: approvers are not allowed to edit document content, only comments and approval decisions are permitted")
ErrInvalidStateTransition = errors.New("docgov: invalid document lifecycle state transition")
ErrRejectReasonRequired = errors.New("docgov: rejection reason must not be empty")
ErrUnauthorizedActor = errors.New("docgov: actor is not authorized for this role or action")
ErrProposalNotFound = errors.New("docgov: change proposal not found")
ErrProposalAlreadyDecided = errors.New("docgov: change proposal has already been decided")
ErrChangeProposalPending = errors.New("docgov: document is claimed and has pending change proposal waiting for author approval")
ErrDocumentBusy = errors.New("docgov: document edit lease is held by another writer")
ErrStaleFencing = errors.New("docgov: stale lease fencing token")
ErrLeaseExpired = errors.New("docgov: document edit lease has expired")
ErrLeaseNotHeld = errors.New("docgov: caller does not hold the document edit lease")
ErrForceEditNotAllowed = errors.New("docgov: force_direct_edit requires an approved_by_author proposal and unlocked drafting document")
ErrContentProjectionMissing = errors.New("docgov: document content projection missing")
ErrContentProjectionEmpty = errors.New("docgov: document content projection empty")
ErrContentIntegrityMismatch = errors.New("docgov: document content integrity mismatch")
// Re-export stable vocabulary so domain code keeps short names.
// Cross-package consumers should import contracts/vocabulary for State/Role/Err.
type (
DocumentState = vocabulary.DocumentState
UserRole = vocabulary.UserRole
LeaseHolderType = vocabulary.LeaseHolderType
CommentType = vocabulary.CommentType
)
const (
ErrCodeDocumentNotFound = "DOCUMENT_NOT_FOUND"
ErrCodeProjectionMissing = "DOCUMENT_CONTENT_PROJECTION_MISSING"
ErrCodeProjectionEmpty = "DOCUMENT_CONTENT_PROJECTION_EMPTY"
ErrCodeIntegrityMismatch = "DOCUMENT_CONTENT_INTEGRITY_MISMATCH"
ErrCodeProjectionDrift = "DOCUMENT_CONTENT_PROJECTION_DRIFT"
StateDrafting = vocabulary.StateDrafting
StateReviewing = vocabulary.StateReviewing
StateApproving = vocabulary.StateApproving
StateApproved = vocabulary.StateApproved
StateRejected = vocabulary.StateRejected
RoleAuthor = vocabulary.RoleAuthor
RoleReviewer = vocabulary.RoleReviewer
RoleApprover = vocabulary.RoleApprover
RoleSystem = vocabulary.RoleSystem
LeaseHolderNone = vocabulary.LeaseHolderNone
LeaseHolderSquad = vocabulary.LeaseHolderSquad
LeaseHolderHuman = vocabulary.LeaseHolderHuman
DefaultSquadLeaseTTL = vocabulary.DefaultSquadLeaseTTL
DefaultHumanLeaseTTL = vocabulary.DefaultHumanLeaseTTL
CommentGeneral = vocabulary.CommentGeneral
CommentSuggestion = vocabulary.CommentSuggestion
CommentRejection = vocabulary.CommentRejection
ErrCodeDocumentNotFound = vocabulary.ErrCodeDocumentNotFound
ErrCodeProjectionMissing = vocabulary.ErrCodeProjectionMissing
ErrCodeProjectionEmpty = vocabulary.ErrCodeProjectionEmpty
ErrCodeIntegrityMismatch = vocabulary.ErrCodeIntegrityMismatch
ErrCodeProjectionDrift = vocabulary.ErrCodeProjectionDrift
)
var (
ErrDocumentNotFound = vocabulary.ErrDocumentNotFound
ErrDocumentLocked = vocabulary.ErrDocumentLocked
ErrOnlyAuthorCanEdit = vocabulary.ErrOnlyAuthorCanEdit
ErrReviewerCannotEdit = vocabulary.ErrReviewerCannotEdit
ErrApproverCannotEdit = vocabulary.ErrApproverCannotEdit
ErrInvalidStateTransition = vocabulary.ErrInvalidStateTransition
ErrRejectReasonRequired = vocabulary.ErrRejectReasonRequired
ErrUnauthorizedActor = vocabulary.ErrUnauthorizedActor
ErrProposalNotFound = vocabulary.ErrProposalNotFound
ErrProposalAlreadyDecided = vocabulary.ErrProposalAlreadyDecided
ErrChangeProposalPending = vocabulary.ErrChangeProposalPending
ErrDocumentBusy = vocabulary.ErrDocumentBusy
ErrStaleFencing = vocabulary.ErrStaleFencing
ErrLeaseExpired = vocabulary.ErrLeaseExpired
ErrLeaseNotHeld = vocabulary.ErrLeaseNotHeld
ErrForceEditNotAllowed = vocabulary.ErrForceEditNotAllowed
ErrContentProjectionMissing = vocabulary.ErrContentProjectionMissing
ErrContentProjectionEmpty = vocabulary.ErrContentProjectionEmpty
ErrContentIntegrityMismatch = vocabulary.ErrContentIntegrityMismatch
)
// ContentError is a structured content-read failure. 404 is only for a missing record.
@@ -73,21 +105,6 @@ func NewContentError(code string, cause error, retryable, needsAdmin bool) *Cont
return &ContentError{Code: code, Message: msg, Retryable: retryable, NeedsAdmin: needsAdmin, cause: cause}
}
// LeaseHolderType 编辑租约持有者类型
type LeaseHolderType string
const (
LeaseHolderNone LeaseHolderType = ""
LeaseHolderSquad LeaseHolderType = "squad"
LeaseHolderHuman LeaseHolderType = "human"
)
// DefaultSquadLeaseTTL 小队任务默认租约时长
const DefaultSquadLeaseTTL = 30 * time.Minute
// DefaultHumanLeaseTTL 人工编辑会话默认租约时长
const DefaultHumanLeaseTTL = 15 * time.Minute
// DocumentBusyError 携带租约占用详情,便于 HTTP 409 与 UI 提示
type DocumentBusyError struct {
DocID string `json:"doc_id"`
@@ -107,27 +124,6 @@ func (e *DocumentBusyError) Error() string {
func (e *DocumentBusyError) Unwrap() error { return ErrDocumentBusy }
// DocumentState 文档生命周期流转状态
type DocumentState string
const (
StateDrafting DocumentState = "drafting" // 编制中 (草稿)
StateReviewing DocumentState = "reviewing" // 审核中 (待审核, 锁定)
StateApproving DocumentState = "approving" // 批准中 (待批准, 锁定)
StateApproved DocumentState = "approved" // 已批准 (归档生效, 终态锁定)
StateRejected DocumentState = "rejected" // 已驳回 (中间状态或流转标记)
)
// UserRole 治理责任人角色 (各有且仅有一个责任人)
type UserRole string
const (
RoleAuthor UserRole = "author" // 编制人
RoleReviewer UserRole = "reviewer" // 审核人
RoleApprover UserRole = "approver" // 批准人
RoleSystem UserRole = "system" // 系统/智能体小队
)
// DocumentConfig 受控文档流转与退回配置
type DocumentConfig struct {
RejectRollbackTo DocumentState `json:"reject_rollback_to"` // 驳回退回目标: drafting 或 reviewing
@@ -221,15 +217,6 @@ func (d *DocumentRecord) ClearLease() {
d.LeaseFencingToken = ""
}
// CommentType 批注类型
type CommentType string
const (
CommentGeneral CommentType = "comment" // 普通批注
CommentSuggestion CommentType = "suggestion" // 修改建议
CommentRejection CommentType = "reject_reason" // 驳回原因
)
// DocumentComment 批注与修改意见实体
type DocumentComment struct {
ID int64 `json:"id"`
+8 -8
View File
@@ -7,8 +7,8 @@ import (
"chat-agent-system/internal/auth"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
)
// failClosedAuthz 生产/DB 模式在授权依赖异常时默认拒绝。
@@ -180,21 +180,21 @@ func (s *Server) requireDocGovDocAccess(w http.ResponseWriter, r *http.Request,
}
// resolveDocGovRole 服务端按文档职责分配解析 docgov 角色,不信任客户端自报角色。
func (s *Server) resolveDocGovRole(ctx context.Context, docID string, user *auth.UserAccount) docgov.UserRole {
func (s *Server) resolveDocGovRole(ctx context.Context, docID string, user *auth.UserAccount) vocabulary.UserRole {
if user == nil || s.gov == nil {
return docgov.UserRole("")
return vocabulary.UserRole("")
}
doc, err := s.gov.GetDocument(ctx, docID)
if err != nil || doc == nil {
return docgov.UserRole("")
return vocabulary.UserRole("")
}
switch user.ID {
case doc.AuthorID:
return docgov.RoleAuthor
return vocabulary.RoleAuthor
case doc.ReviewerID:
return docgov.RoleReviewer
return vocabulary.RoleReviewer
case doc.ApproverID:
return docgov.RoleApprover
return vocabulary.RoleApprover
}
return docgov.UserRole("")
return vocabulary.UserRole("")
}
@@ -12,6 +12,7 @@ import (
"chat-agent-system/internal/app/docflow"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/freedoc"
@@ -213,7 +214,7 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
// This UI has no edit-session heartbeat: release the request-scoped human lease on every exit
// (success, 409, projection failure) so a 15m residual cannot block a later squad run.
if kind == "controlled" && s.gov != nil {
if err := s.gov.AssertHumanCanEdit(r.Context(), docID, user.ID, docgov.RoleAuthor); err != nil {
if err := s.gov.AssertHumanCanEdit(r.Context(), docID, user.ID, vocabulary.RoleAuthor); err != nil {
writeDocGovError(w, err)
return
}
@@ -222,7 +223,7 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
holderID = "human_anon"
}
defer func() {
_, _ = s.gov.ReleaseLease(context.Background(), docID, string(docgov.LeaseHolderHuman), holderID)
_, _ = s.gov.ReleaseLease(context.Background(), docID, string(vocabulary.LeaseHolderHuman), holderID)
}()
}
@@ -250,7 +251,7 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
res, err := cmds.HumanEdit(r.Context(), docflow.Principal{UserID: user.ID, Name: user.Name, Role: docgov.RoleAuthor}, docflow.HumanEditCommand{
res, err := cmds.HumanEdit(r.Context(), docflow.Principal{UserID: user.ID, Name: user.Name, Role: vocabulary.RoleAuthor}, docflow.HumanEditCommand{
DocID: docID,
Request: docrevision.HumanRevisionRequest{
DocID: docID, Markdown: body.Markdown, ExpectedHeadSnapshot: expectedHead, ExpectedRevisionNo: expectedRev,
@@ -279,7 +280,7 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
if _, err := s.gov.SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{
DocID: docID,
ActorID: user.ID,
ActorRole: docgov.RoleAuthor,
ActorRole: vocabulary.RoleAuthor,
ActorName: user.Name,
MarkdownBody: body.Markdown,
}); err != nil {
@@ -372,7 +373,7 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
return
}
st, err := cmds.AgentCommit(r.Context(), docflow.Principal{UserID: user.ID, Name: user.Name, Role: docgov.RoleAuthor}, docflow.AgentCommitCommand{
st, err := cmds.AgentCommit(r.Context(), docflow.Principal{UserID: user.ID, Name: user.Name, Role: vocabulary.RoleAuthor}, docflow.AgentCommitCommand{
DocID: docID, CandidateID: cid,
Decision: docrevision.CommitAgentDecision{
Decision: body.Decision, Confirmed: body.Confirmed,
+21 -20
View File
@@ -16,6 +16,7 @@ import (
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/chat"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/httpx"
@@ -431,9 +432,9 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
func writeTaskError(w http.ResponseWriter, err error, payload any) {
status := http.StatusInternalServerError
if errors.Is(err, docgov.ErrDocumentBusy) {
if errors.Is(err, vocabulary.ErrDocumentBusy) {
status = http.StatusConflict
} else if errors.Is(err, docgov.ErrDocumentLocked) || errors.Is(err, docgov.ErrForceEditNotAllowed) {
} else if errors.Is(err, vocabulary.ErrDocumentLocked) || errors.Is(err, vocabulary.ErrForceEditNotAllowed) {
status = http.StatusConflict
} else if errors.Is(err, projectworkflow.ErrPrerequisiteNotMet) {
status = http.StatusBadRequest
@@ -454,19 +455,19 @@ func writeDocGovError(w http.ResponseWriter, err error) {
})
return
}
if errors.Is(err, docgov.ErrDocumentBusy) {
if errors.Is(err, vocabulary.ErrDocumentBusy) {
writeSafeError(w, nil, http.StatusConflict, err.Error())
return
}
if errors.Is(err, docgov.ErrDocumentLocked) {
if errors.Is(err, vocabulary.ErrDocumentLocked) {
writeSafeError(w, nil, http.StatusConflict, err.Error())
return
}
if errors.Is(err, docgov.ErrDocumentNotFound) {
if errors.Is(err, vocabulary.ErrDocumentNotFound) {
writeSafeError(w, nil, http.StatusNotFound, err.Error())
return
}
if errors.Is(err, docgov.ErrLeaseNotHeld) || errors.Is(err, docgov.ErrLeaseExpired) {
if errors.Is(err, vocabulary.ErrLeaseNotHeld) || errors.Is(err, vocabulary.ErrLeaseExpired) {
writeSafeError(w, nil, http.StatusConflict, err.Error())
return
}
@@ -713,7 +714,7 @@ func (s *Server) handleDocGovSubmitReview(w http.ResponseWriter, r *http.Request
return
}
doc, err := s.gov.SubmitForReview(r.Context(), req.DocID, user.ID, docgov.RoleAuthor, user.Name)
doc, err := s.gov.SubmitForReview(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor, user.Name)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, "submit review failed: "+err.Error())
return
@@ -759,13 +760,13 @@ func (s *Server) handleDocGovReviewDecision(w http.ResponseWriter, r *http.Reque
var err error
if action == "approve" {
doc, err = s.gov.ReviewApprove(r.Context(), req.DocID, user.ID, docgov.RoleReviewer, user.Name, req.Comment)
doc, err = s.gov.ReviewApprove(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, req.Comment)
} else if action == "reject" {
reason := req.Reason
if reason == "" {
reason = req.Comment
}
doc, err = s.gov.ReviewReject(r.Context(), req.DocID, user.ID, docgov.RoleReviewer, user.Name, reason)
doc, err = s.gov.ReviewReject(r.Context(), req.DocID, user.ID, vocabulary.RoleReviewer, user.Name, reason)
} else {
writeSafeError(w, r, http.StatusBadRequest, "invalid action: must be approve or reject")
return
@@ -806,7 +807,7 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
p := docflow.Principal{UserID: user.ID, Name: user.Name, Role: docgov.RoleApprover}
p := docflow.Principal{UserID: user.ID, Name: user.Name, Role: vocabulary.RoleApprover}
var doc *docgov.DocumentRecord
var err error
@@ -818,7 +819,7 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req
reason = req.Comment
}
doc, err = cmds.Reject(r.Context(), p, docflow.DecisionCommand{
DocID: req.DocID, RejectReason: reason, RollbackTo: docgov.DocumentState(req.RollbackTo),
DocID: req.DocID, RejectReason: reason, RollbackTo: vocabulary.DocumentState(req.RollbackTo),
})
} else {
writeSafeError(w, r, http.StatusBadRequest, "invalid action: must be approve or reject")
@@ -890,7 +891,7 @@ func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http.
req.RiskAssessment,
)
if err != nil {
if errors.Is(err, docgov.ErrDocumentNotFound) {
if errors.Is(err, vocabulary.ErrDocumentNotFound) {
writeSafeError(w, r, http.StatusNotFound, "document not found")
return
}
@@ -933,11 +934,11 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque
proposal, err := s.gov.DecideModificationProposal(r.Context(), req.ProposalID, user.ID, req.Approve, req.Reason)
if err != nil {
if errors.Is(err, docgov.ErrProposalNotFound) {
if errors.Is(err, vocabulary.ErrProposalNotFound) {
writeSafeError(w, r, http.StatusNotFound, "proposal not found")
return
}
if errors.Is(err, docgov.ErrUnauthorizedActor) {
if errors.Is(err, vocabulary.ErrUnauthorizedActor) {
writeSafeError(w, r, http.StatusForbidden, "decide proposal failed: "+err.Error())
return
}
@@ -1004,9 +1005,9 @@ func (s *Server) handleDocGovAddComment(w http.ResponseWriter, r *http.Request)
return
}
cType := docgov.CommentType(req.CommentType)
cType := vocabulary.CommentType(req.CommentType)
if cType == "" {
cType = docgov.CommentGeneral
cType = vocabulary.CommentGeneral
}
comment, err := s.gov.AddComment(
@@ -1302,7 +1303,7 @@ func (s *Server) handleDocGovSaveContent(w http.ResponseWriter, r *http.Request)
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
role := docgov.RoleAuthor
role := vocabulary.RoleAuthor
res, err := s.gov.SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{
DocID: req.DocID,
ActorID: user.ID,
@@ -1339,8 +1340,8 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
if docgov.LeaseHolderType(req.HolderType) == docgov.LeaseHolderHuman {
if err := s.gov.CheckHumanCanEdit(r.Context(), req.DocID, user.ID, docgov.RoleAuthor); err != nil {
if vocabulary.LeaseHolderType(req.HolderType) == vocabulary.LeaseHolderHuman {
if err := s.gov.CheckHumanCanEdit(r.Context(), req.DocID, user.ID, vocabulary.RoleAuthor); err != nil {
writeDocGovError(w, err)
return
}
@@ -1353,7 +1354,7 @@ func (s *Server) handleDocGovAcquireLease(w http.ResponseWriter, r *http.Request
ttl := time.Duration(req.TTLSeconds) * time.Second
doc, err := s.gov.AcquireLease(r.Context(), docgov.LeaseAcquireRequest{
DocID: req.DocID,
HolderType: docgov.LeaseHolderType(req.HolderType),
HolderType: vocabulary.LeaseHolderType(req.HolderType),
HolderID: req.HolderID,
WorkDir: workDir,
TTL: ttl,
+2 -2
View File
@@ -8,7 +8,7 @@ import (
"strconv"
"strings"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/projectworkflow"
)
@@ -52,7 +52,7 @@ func (s *Server) lineageDocVisible(ctx context.Context, projectID, userID int64,
func (s *Server) lineageDocPlacement(ctx context.Context, projectID int64, docID string) (taskKey string, placed bool, ok bool) {
if s.gov != nil {
doc, err := s.gov.GetDocument(ctx, docID)
if err != nil && !errors.Is(err, docgov.ErrDocumentNotFound) {
if err != nil && !errors.Is(err, vocabulary.ErrDocumentNotFound) {
return "", false, false
}
if err == nil && doc != nil {
+6 -5
View File
@@ -10,6 +10,7 @@ import (
"time"
"chat-agent-system/internal/app/docflow"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docquery"
"chat-agent-system/internal/docrevision"
@@ -132,7 +133,7 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R
}
if errors.Is(err, docquery.ErrClosed) {
writeJSONResponse(w, http.StatusNotFound, map[string]any{
"error_code": docgov.ErrCodeDocumentNotFound, "message": "document not found",
"error_code": vocabulary.ErrCodeDocumentNotFound, "message": "document not found",
})
return
}
@@ -150,7 +151,7 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R
func (s *Server) loadControlledDocumentContent(ctx context.Context, docID string) (*docgov.DocumentContent, error) {
if s == nil || s.gov == nil {
return nil, docgov.ErrDocumentNotFound
return nil, vocabulary.ErrDocumentNotFound
}
svc := &docquery.Service{Gov: s.gov, Rev: s.docRev(), RepoRoot: s.repoRoot()}
view, err := svc.Get(ctx, docquery.Principal{UserID: 1}, docquery.DocRef{DocID: docID})
@@ -191,7 +192,7 @@ func writeControlledContentError(w http.ResponseWriter, r *http.Request, err err
var ce *docgov.ContentError
if errors.As(err, &ce) {
status := http.StatusConflict
if ce.Code == docgov.ErrCodeDocumentNotFound {
if ce.Code == vocabulary.ErrCodeDocumentNotFound {
status = http.StatusNotFound
}
writeJSONResponse(w, status, map[string]any{
@@ -207,9 +208,9 @@ func writeControlledContentError(w http.ResponseWriter, r *http.Request, err err
})
return
}
if errors.Is(err, docgov.ErrDocumentNotFound) {
if errors.Is(err, vocabulary.ErrDocumentNotFound) {
writeJSONResponse(w, http.StatusNotFound, map[string]any{
"error_code": docgov.ErrCodeDocumentNotFound, "message": err.Error(),
"error_code": vocabulary.ErrCodeDocumentNotFound, "message": err.Error(),
"retryable": false, "needs_admin": false, "correlation_id": corr,
})
return
+3 -2
View File
@@ -13,6 +13,7 @@ import (
"chat-agent-system/internal/agent"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectinstance"
@@ -409,7 +410,7 @@ func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRu
return nil, nil
}
doc, err := s.gov.GetDocument(ctx, docID)
if errors.Is(err, docgov.ErrDocumentNotFound) {
if errors.Is(err, vocabulary.ErrDocumentNotFound) {
return nil, nil
}
if err != nil {
@@ -419,7 +420,7 @@ func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRu
return nil, nil
}
// 已结束的小队 run 由 AcquireLease 的孤儿回收逻辑处理,不在这里误拦截。
if doc.LeaseHolderType == docgov.LeaseHolderSquad && s.taskRuns != nil {
if doc.LeaseHolderType == vocabulary.LeaseHolderSquad && s.taskRuns != nil {
if run, runErr := s.taskRuns.GetRun(doc.LeaseHolderID); runErr == nil && run.Status != "running" {
return nil, nil
}