New workflow instances store bd_* task keys, but chat still sends the old keys. Scope and document access now treat those aliases as the same task so a live instance no longer fails closed on an exact string miss. Co-authored-by: Cursor <cursoragent@cursor.com>
231 lines
7.2 KiB
Go
231 lines
7.2 KiB
Go
package projectinstance
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"chat-agent-system/internal/workflowdef"
|
||
)
|
||
|
||
const (
|
||
ScopeWorkflowStage = "workflow_stage"
|
||
ScopePreActivation = "pre_activation"
|
||
ScopeLegacyUnscoped = "legacy_unscoped"
|
||
ScopeNonProjectPrivate = "non_project_private"
|
||
)
|
||
|
||
var ErrTaskScopeNotResolved = errors.New("无法唯一解析文档任务的工作流阶段作用域")
|
||
|
||
// MessageScope 不可变消息工作流作用域(服务端解析,客户端不可伪造)。
|
||
type MessageScope struct {
|
||
ScopeKind string `json:"scope_kind"`
|
||
WorkflowInstanceID int64 `json:"workflow_instance_id,omitempty"`
|
||
StageInstanceID int64 `json:"stage_instance_id,omitempty"`
|
||
StageKey string `json:"stage_key,omitempty"`
|
||
}
|
||
|
||
// ApplyScopeToMetadata 将作用域写入 metadata(冗余,便于审计与兼容)。
|
||
func ApplyScopeToMetadata(meta map[string]any, scope *MessageScope) {
|
||
if meta == nil || scope == nil {
|
||
return
|
||
}
|
||
meta["scope_kind"] = scope.ScopeKind
|
||
if scope.WorkflowInstanceID > 0 {
|
||
meta["workflow_instance_id"] = scope.WorkflowInstanceID
|
||
}
|
||
if scope.StageInstanceID > 0 {
|
||
meta["stage_instance_id"] = scope.StageInstanceID
|
||
}
|
||
if scope.StageKey != "" {
|
||
meta["stage_key"] = scope.StageKey
|
||
}
|
||
}
|
||
|
||
// ScopeFromMetadata 从 metadata 提取作用域(历史兼容)。
|
||
func ScopeFromMetadata(metadata map[string]any) MessageScope {
|
||
out := MessageScope{ScopeKind: ScopeLegacyUnscoped}
|
||
if metadata == nil {
|
||
return out
|
||
}
|
||
if sk, ok := metadata["scope_kind"].(string); ok && sk != "" {
|
||
out.ScopeKind = sk
|
||
}
|
||
if v, ok := metadata["workflow_instance_id"].(float64); ok && v > 0 {
|
||
out.WorkflowInstanceID = int64(v)
|
||
} else if v, ok := metadata["workflow_instance_id"].(int64); ok && v > 0 {
|
||
out.WorkflowInstanceID = v
|
||
}
|
||
if v, ok := metadata["stage_instance_id"].(float64); ok && v > 0 {
|
||
out.StageInstanceID = int64(v)
|
||
} else if v, ok := metadata["stage_instance_id"].(int64); ok && v > 0 {
|
||
out.StageInstanceID = v
|
||
}
|
||
out.StageKey = MessageStageKey(metadata)
|
||
return out
|
||
}
|
||
|
||
// ResolveMessageScopeForChat 解析普通聊天消息作用域。
|
||
func (s *Service) ResolveMessageScopeForChat(ctx context.Context, projectID int64, activeStageKey string) (*MessageScope, error) {
|
||
if projectID <= 0 {
|
||
return &MessageScope{ScopeKind: ScopeNonProjectPrivate}, nil
|
||
}
|
||
if s == nil || s.store == nil {
|
||
return &MessageScope{ScopeKind: ScopePreActivation}, nil
|
||
}
|
||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||
if err != nil || live == nil {
|
||
return &MessageScope{ScopeKind: ScopePreActivation}, nil
|
||
}
|
||
detail, err := s.store.GetInstanceByID(ctx, live.ID)
|
||
if err != nil || detail == nil {
|
||
return &MessageScope{ScopeKind: ScopePreActivation}, nil
|
||
}
|
||
stageKey := activeStageKey
|
||
if stageKey == "" {
|
||
stageKey = detail.ActiveStageKey
|
||
}
|
||
if stageKey == "" {
|
||
return &MessageScope{
|
||
ScopeKind: ScopePreActivation,
|
||
WorkflowInstanceID: live.ID,
|
||
}, nil
|
||
}
|
||
for _, st := range detail.Stages {
|
||
if st.StageKey == stageKey {
|
||
return &MessageScope{
|
||
ScopeKind: ScopeWorkflowStage,
|
||
WorkflowInstanceID: live.ID,
|
||
StageInstanceID: st.ID,
|
||
StageKey: st.StageKey,
|
||
}, nil
|
||
}
|
||
}
|
||
return &MessageScope{
|
||
ScopeKind: ScopeWorkflowStage,
|
||
WorkflowInstanceID: live.ID,
|
||
StageKey: stageKey,
|
||
}, nil
|
||
}
|
||
|
||
// ResolveMessageScopeForTask 解析文档任务消息作用域。
|
||
//
|
||
// 经 GetGoverningInstanceForAccess 三态:
|
||
// - nil(从未激活)→ ScopePreActivation(MessageVisible:仅 session owner)
|
||
// - live 系(active / awaiting_stage_confirmation / termination_requested)→ 阶段作用域
|
||
// - completed / terminated → ErrTaskScopeNotResolved(不得当预激活;access gate 应已先拒)
|
||
//
|
||
// 存储错误原样上抛(fail-closed)。禁止仅凭 GetLiveInstance=nil 放行。
|
||
func (s *Service) ResolveMessageScopeForTask(ctx context.Context, projectID int64, taskKey string) (*MessageScope, error) {
|
||
if projectID <= 0 || taskKey == "" {
|
||
return nil, fmt.Errorf("%w: project_id=%d task_key=%q", ErrTaskScopeNotResolved, projectID, taskKey)
|
||
}
|
||
if s == nil || s.store == nil {
|
||
return nil, ErrTaskScopeNotResolved
|
||
}
|
||
gov, err := s.store.GetGoverningInstanceForAccess(ctx, projectID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if gov == nil {
|
||
return &MessageScope{ScopeKind: ScopePreActivation}, nil
|
||
}
|
||
if IsClosedInstanceStatus(gov.Status) {
|
||
return nil, fmt.Errorf("%w: workflow instance closed (status=%s) for project %d", ErrTaskScopeNotResolved, gov.Status, projectID)
|
||
}
|
||
if !IsLiveInstanceStatus(gov.Status) {
|
||
return nil, fmt.Errorf("%w: unexpected governing status %q for project %d", ErrTaskScopeNotResolved, gov.Status, projectID)
|
||
}
|
||
detail, err := s.store.GetInstanceByID(ctx, gov.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if detail == nil {
|
||
return nil, ErrTaskScopeNotResolved
|
||
}
|
||
var matches []MessageScope
|
||
for _, st := range detail.Stages {
|
||
for _, dt := range st.DocTasks {
|
||
if workflowdef.SameTaskKey(dt.TaskKey, taskKey) {
|
||
matches = append(matches, MessageScope{
|
||
ScopeKind: ScopeWorkflowStage,
|
||
WorkflowInstanceID: gov.ID,
|
||
StageInstanceID: st.ID,
|
||
StageKey: st.StageKey,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
if len(matches) == 0 {
|
||
stageKey := TaskKeyToStage(gov.ConfigSnapshot, taskKey)
|
||
if stageKey == "" {
|
||
return nil, fmt.Errorf("%w: task_key %q not in instance snapshot", ErrTaskScopeNotResolved, taskKey)
|
||
}
|
||
for _, st := range detail.Stages {
|
||
if st.StageKey == stageKey {
|
||
return &MessageScope{
|
||
ScopeKind: ScopeWorkflowStage,
|
||
WorkflowInstanceID: gov.ID,
|
||
StageInstanceID: st.ID,
|
||
StageKey: st.StageKey,
|
||
}, nil
|
||
}
|
||
}
|
||
return nil, ErrTaskScopeNotResolved
|
||
}
|
||
if len(matches) > 1 {
|
||
first := matches[0]
|
||
for _, m := range matches[1:] {
|
||
if m.StageKey != first.StageKey {
|
||
return nil, fmt.Errorf("%w: ambiguous task_key %q", ErrTaskScopeNotResolved, taskKey)
|
||
}
|
||
}
|
||
}
|
||
scope := matches[0]
|
||
return &scope, nil
|
||
}
|
||
|
||
// MessageVisible 判断消息对 viewer 是否可见(需会话 owner 与阶段授权上下文)。
|
||
func MessageVisible(scope MessageScope, access *StageAccess, sessionOwnerID, viewerUserID int64) bool {
|
||
if viewerUserID <= 0 {
|
||
return false
|
||
}
|
||
switch scope.ScopeKind {
|
||
case ScopeNonProjectPrivate, ScopePreActivation:
|
||
return sessionOwnerID > 0 && viewerUserID == sessionOwnerID
|
||
case ScopeLegacyUnscoped:
|
||
if access == nil || !access.Restricted {
|
||
return sessionOwnerID <= 0 || viewerUserID == sessionOwnerID
|
||
}
|
||
return sessionOwnerID > 0 && viewerUserID == sessionOwnerID
|
||
case ScopeWorkflowStage, "":
|
||
if access == nil || !access.Restricted {
|
||
return true
|
||
}
|
||
stageKey := scope.StageKey
|
||
if stageKey == "" {
|
||
return sessionOwnerID > 0 && viewerUserID == sessionOwnerID
|
||
}
|
||
return access.StageKeyAllowed(stageKey)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// MessageRecordVisible 从 ChatMessageRecord 字段判定可见性。
|
||
func MessageRecordVisible(
|
||
scopeKind, stageKey string,
|
||
metadata map[string]any,
|
||
access *StageAccess,
|
||
sessionOwnerID, viewerUserID int64,
|
||
) bool {
|
||
scope := MessageScope{ScopeKind: scopeKind, StageKey: stageKey}
|
||
if scope.ScopeKind == "" {
|
||
scope = ScopeFromMetadata(metadata)
|
||
}
|
||
if scope.StageKey == "" {
|
||
scope.StageKey = MessageStageKey(metadata)
|
||
}
|
||
return MessageVisible(scope, access, sessionOwnerID, viewerUserID)
|
||
}
|