Files
weichenandCursor 8d22fa3827 fix(projectinstance): resolve legacy task keys against namespaced snapshots
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>
2026-09-21 05:03:08 +00:00

231 lines
7.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}