diff --git a/deploy/postgres/goose/00033_2026-09-14-task-run-blocked-pending-governance.sql b/deploy/postgres/goose/00033_2026-09-14-task-run-blocked-pending-governance.sql new file mode 100644 index 00000000..90877f1e --- /dev/null +++ b/deploy/postgres/goose/00033_2026-09-14-task-run-blocked-pending-governance.sql @@ -0,0 +1,37 @@ +-- +goose Up +-- Pre-activation document runs stay 202 but persist blocked_pending_governance +-- without starting a squad or taking a lease. + +ALTER TABLE task_runs DROP CONSTRAINT IF EXISTS chk_task_run_status; + +ALTER TABLE task_runs ADD CONSTRAINT chk_task_run_status CHECK ( + status IN ( + 'pending', + 'running', + 'completed', + 'failed', + 'cancelled', + 'finalizing_failed', + 'quality_failed', + 'interrupted', + 'pending_author_confirmation', + 'blocked_pending_governance' + ) +); + +-- +goose Down +ALTER TABLE task_runs DROP CONSTRAINT IF EXISTS chk_task_run_status; + +ALTER TABLE task_runs ADD CONSTRAINT chk_task_run_status CHECK ( + status IN ( + 'pending', + 'running', + 'completed', + 'failed', + 'cancelled', + 'finalizing_failed', + 'quality_failed', + 'interrupted', + 'pending_author_confirmation' + ) +); diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index de68abb1..949eda82 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "net/http" + "strconv" + "strings" "chat-agent-system/internal/auth" "chat-agent-system/internal/chat" @@ -63,14 +65,38 @@ func (s *Server) enforceWorkflowDocTaskAction(w http.ResponseWriter, ctx context } func (s *Server) enforceDocGovWorkflowAction(w http.ResponseWriter, ctx context.Context, userID int64, docID, action string) bool { - if s.svc == nil || s.svc.DocGov() == nil || docID == "" { + if docID == "" { return true } - doc, err := s.svc.DocGov().GetDocument(ctx, docID) - if err != nil || doc == nil || doc.ProjectID <= 0 || doc.TaskKey == "" { - return true + if s.svc != nil && s.svc.DocGov() != nil { + doc, err := s.svc.DocGov().GetDocument(ctx, docID) + if err == nil && doc != nil && doc.ProjectID > 0 && doc.TaskKey != "" { + return s.enforceWorkflowDocTaskAction(w, ctx, doc.ProjectID, userID, doc.TaskKey, action) + } } - return s.enforceWorkflowDocTaskAction(w, ctx, doc.ProjectID, userID, doc.TaskKey, action) + if projectID, taskKey, ok := parseCanonicalWorkflowDocID(docID); ok { + return s.enforceWorkflowDocTaskAction(w, ctx, projectID, userID, taskKey, action) + } + return true +} + +// parseCanonicalWorkflowDocID 解析 doc_proj{projectID}_{taskKey}。 +// 模板尚未落 document_records 时,送审/审核/批准仍按任务职责鉴权。 +func parseCanonicalWorkflowDocID(docID string) (int64, string, bool) { + const prefix = "doc_proj" + if !strings.HasPrefix(docID, prefix) { + return 0, "", false + } + rest := strings.TrimPrefix(docID, prefix) + idx := strings.IndexByte(rest, '_') + if idx <= 0 || idx >= len(rest)-1 { + return 0, "", false + } + projectID, err := strconv.ParseInt(rest[:idx], 10, 64) + if err != nil || projectID <= 0 { + return 0, "", false + } + return projectID, rest[idx+1:], true } func (s *Server) writeDocAccessDecision(w http.ResponseWriter, decision projectinstance.DocAccessDecision) { diff --git a/internal/server/stage_access_helpers_test.go b/internal/server/stage_access_helpers_test.go new file mode 100644 index 00000000..4599e91e --- /dev/null +++ b/internal/server/stage_access_helpers_test.go @@ -0,0 +1,26 @@ +package server + +import "testing" + +func TestParseCanonicalWorkflowDocID(t *testing.T) { + t.Parallel() + cases := []struct { + in string + wantID int64 + wantKey string + ok bool + }{ + {"doc_proj6201_intake", 6201, "intake", true}, + {"doc_proj12_feasibility_reg", 12, "feasibility_reg", true}, + {"doc-p6201-intake", 0, "", false}, + {"doc_proj_intake", 0, "", false}, + {"doc_proj0_intake", 0, "", false}, + {"", 0, "", false}, + } + for _, tc := range cases { + gotID, gotKey, ok := parseCanonicalWorkflowDocID(tc.in) + if ok != tc.ok || gotID != tc.wantID || gotKey != tc.wantKey { + t.Fatalf("%q -> (%d,%q,%v) want (%d,%q,%v)", tc.in, gotID, gotKey, ok, tc.wantID, tc.wantKey, tc.ok) + } + } +} diff --git a/internal/taskrun/types.go b/internal/taskrun/types.go index 9f7c707b..6a2a3c83 100644 --- a/internal/taskrun/types.go +++ b/internal/taskrun/types.go @@ -36,7 +36,7 @@ type RunRecord struct { WorkflowInstanceID int64 StageInstanceID int64 WorkflowStageKey string - Status string // pending | running | completed | failed | cancelled | pending_author_confirmation | … + Status string // pending | running | completed | failed | cancelled | pending_author_confirmation | blocked_pending_governance | … WorkspaceID string AssistantMsgID int64 WorkerID string diff --git a/web/e2e/governance-action-level-authz.spec.ts b/web/e2e/governance-action-level-authz.spec.ts index b16e61fd..886680ac 100644 --- a/web/e2e/governance-action-level-authz.spec.ts +++ b/web/e2e/governance-action-level-authz.spec.ts @@ -39,8 +39,8 @@ async function login(page: Page, username: string, password: string) { } async function goToDutyStep(page: Page) { - const approval = page.getByText('纳入人工审批').first(); - const selectHint = page.getByText('选择要激活的已发布工作流版本'); + const approval = page.locator('label', { hasText: '纳入人工审批' }).first(); + const selectHint = page.getByText('选择要激活的已发布工作流版本。激活后将冻结配置,运行中不可切换。'); await expect(selectHint.or(approval)).toBeVisible({ timeout: 20_000 }); if (await approval.isVisible().catch(() => false)) { return; @@ -112,13 +112,15 @@ test.describe.serial('governance action-level authz', () => { await expect(page.locator('[data-chat-role="assistant"]').last()).toBeVisible({ timeout: 45_000 }); await shot(page, '02-casual-chat.png'); - await input.fill('帮我生成立项文档'); + // 「生成立项文档」会命中前端私密草稿捷径;编制用户初始需求收集表才会 POST /tasks/runs。 + const generatePrompt = '请编制用户初始需求收集表'; + await input.fill(generatePrompt); await page.getByRole('button', { name: /发送/ }).click(); - await expect(page.getByText('帮我生成立项文档')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(generatePrompt)).toBeVisible({ timeout: 20_000 }); await expect(page.getByText(GOVERNANCE_BLOCKED_HINT)).toBeVisible({ timeout: 45_000 }); await expect(page.getByText('待激活工作流')).toBeVisible({ timeout: 15_000 }); - const blocked = runCreates.find((row) => row.body.includes('blocked_pending_governance')); - if (!blocked || blocked.status !== 202) { + const blocked = runCreates.find((row) => row.status === 202 && row.body.includes('blocked_pending_governance')); + if (!blocked) { throw new Error(`expected 202 blocked run, got ${JSON.stringify(runCreates)}`); } await shot(page, '03-blocked-generate.png'); @@ -165,7 +167,12 @@ test.describe.serial('governance action-level authz', () => { } await shot(page, '06-duty-gated-actions.png'); - await page.getByRole('complementary').getByTitle('对话协同会话', { exact: true }).click(); + const leftoverClose = page.getByRole('button', { name: '关闭' }); + if (await leftoverClose.isVisible().catch(() => false)) { + await leftoverClose.click(); + } + await page.keyboard.press('Escape'); + await page.getByRole('complementary').getByTitle('对话协同会话', { exact: true }).click({ force: true }); await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 15_000 }); await page.getByRole('button', { name: '查看工作流设置' }).click(); await expect(page.getByText('当前工作流已冻结,只读查看')).toBeVisible({ timeout: 15_000 });