merge: fix/governance-action-level-authz (blocked status + canonical duty check)

This commit is contained in:
weichen
2026-09-14 08:40:01 +00:00
5 changed files with 109 additions and 13 deletions
@@ -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'
)
);
+31 -5
View File
@@ -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) {
@@ -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)
}
}
}
+1 -1
View File
@@ -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
+14 -7
View File
@@ -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 });