diff --git a/internal/projectinstance/document_access.go b/internal/projectinstance/document_access.go index f92480ab..8d1a6639 100644 --- a/internal/projectinstance/document_access.go +++ b/internal/projectinstance/document_access.go @@ -2,6 +2,8 @@ package projectinstance import ( "context" + + "chat-agent-system/internal/workflowdef" ) const ( @@ -67,7 +69,7 @@ func (s *Service) CheckDocTaskAccess( var stageKey string for _, st := range detail.Stages { for i := range st.DocTasks { - if st.DocTasks[i].TaskKey == taskKey { + if workflowdef.SameTaskKey(st.DocTasks[i].TaskKey, taskKey) { copyDoc := st.DocTasks[i] docInst = ©Doc stageKey = st.StageKey @@ -119,7 +121,7 @@ func (s *Service) CheckDocTaskAccess( func findDocTaskPlan(plan WorkflowPlan, taskKey string) *DocTaskPlan { for _, st := range plan.Stages { for i := range st.DocTasks { - if st.DocTasks[i].TaskKey == taskKey { + if workflowdef.SameTaskKey(st.DocTasks[i].TaskKey, taskKey) { return &st.DocTasks[i] } } diff --git a/internal/projectinstance/message_scope.go b/internal/projectinstance/message_scope.go index cca29b54..17557b3a 100644 --- a/internal/projectinstance/message_scope.go +++ b/internal/projectinstance/message_scope.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + + "chat-agent-system/internal/workflowdef" ) const ( @@ -144,7 +146,7 @@ func (s *Service) ResolveMessageScopeForTask(ctx context.Context, projectID int6 var matches []MessageScope for _, st := range detail.Stages { for _, dt := range st.DocTasks { - if dt.TaskKey == taskKey { + if workflowdef.SameTaskKey(dt.TaskKey, taskKey) { matches = append(matches, MessageScope{ ScopeKind: ScopeWorkflowStage, WorkflowInstanceID: gov.ID, diff --git a/internal/projectinstance/message_scope_test.go b/internal/projectinstance/message_scope_test.go index dedfca98..b5e9731e 100644 --- a/internal/projectinstance/message_scope_test.go +++ b/internal/projectinstance/message_scope_test.go @@ -122,6 +122,70 @@ func TestMessageVisible_LegacyUnscopedOwnerWhenRestricted(t *testing.T) { } } +func TestResolveMessageScopeForTask_LegacyKeyMatchesCanonicalSnapshot(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + mustActivateTestPlan(t, svc, 42, 1) + + store.mu.Lock() + live := store.live[42] + live.ConfigSnapshot.Stages[0].DocTasks[0].TaskKey = "bd_intake" + for i := range store.stages[live.ID] { + for j := range store.stages[live.ID][i].tasks { + if store.stages[live.ID][i].tasks[j].taskKey == "intake" { + store.stages[live.ID][i].tasks[j].taskKey = "bd_intake" + } + } + } + store.mu.Unlock() + + scope, err := svc.ResolveMessageScopeForTask(ctx, 42, "intake") + if err != nil { + t.Fatalf("legacy intake must resolve against bd_intake snapshot: %v", err) + } + if scope.ScopeKind != ScopeWorkflowStage || scope.StageKey != "V0.1_triage" { + t.Fatalf("want V0.1_triage workflow_stage, got %+v", scope) + } +} + +func TestResolveMessageScopeForTask_CanonicalAliasAmbiguousAcrossStages(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 43, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + mustActivateTestPlan(t, svc, 43, 1) + + store.mu.Lock() + live := store.live[43] + live.ConfigSnapshot.Stages = append(live.ConfigSnapshot.Stages, StagePlan{ + StageKey: "V0.5_feasibility", Enabled: true, + DocTasks: []DocTaskPlan{{TaskKey: "bd_intake", Enabled: true}}, + }) + store.stages[live.ID] = append(store.stages[live.ID], memStageRecord{ + id: live.ID + 100, stageKey: "V0.5_feasibility", position: 1, enabled: true, status: StagePending, + tasks: []memDocTaskRecord{{id: 900, taskKey: "bd_intake", enabled: true, status: "pending"}}, + }) + store.mu.Unlock() + + _, err := svc.ResolveMessageScopeForTask(ctx, 43, "intake") + if err == nil || !errors.Is(err, ErrTaskScopeNotResolved) { + t.Fatalf("same task in two stages must stay unresolved, got %v", err) + } +} + +func TestTaskKeyToStage_LegacyAlias(t *testing.T) { + plan := validPlan() + plan.Stages[0].DocTasks[0].TaskKey = "bd_user_requirements" + if got := TaskKeyToStage(plan, "user_requirements"); got != "V0.1_triage" { + t.Fatalf("TaskKeyToStage=%q", got) + } + if got := TaskKeyToStage(plan, "intake"); got != "" { + t.Fatalf("unrelated legacy key must miss, got %q", got) + } +} + func TestScopeFromMetadata_RoundTrip(t *testing.T) { meta := map[string]any{ "scope_kind": ScopeWorkflowStage, diff --git a/internal/projectinstance/stage_access.go b/internal/projectinstance/stage_access.go index 253322d7..497b0699 100644 --- a/internal/projectinstance/stage_access.go +++ b/internal/projectinstance/stage_access.go @@ -152,7 +152,7 @@ func MessageStageKey(metadata map[string]any) string { func TaskKeyToStage(plan WorkflowPlan, taskKey string) string { for _, st := range plan.Stages { for _, dt := range st.DocTasks { - if dt.TaskKey == taskKey { + if workflowdef.SameTaskKey(dt.TaskKey, taskKey) { return st.StageKey } }