fix(web): route 初始需求 to intake and map stage 403 errors

Debug logs showed task runs used user_requirements on V0.1 while the active stage only allows intake. Prefer intake for 初始需求/摸底 phrasing and surface DOC_TASK_NOT_IN_ACTIVE_STAGE instead of SESSION_FORBIDDEN.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 03:28:36 +00:00
co-authored by Cursor
parent 3afd606635
commit d08cbdc2de
4 changed files with 152 additions and 27 deletions
+52 -25
View File
@@ -118,6 +118,7 @@ import {
getTemplateDefinitionByTaskKey,
buildTemplateASTForProject,
} from './engine/templateFactory';
import { matchTemplateDeliverable } from './engine/templateMatch';
import {
loadSessions,
saveSessions,
@@ -2043,7 +2044,28 @@ export default function App() {
};
// 合成网络级异常的结构化错误信息 (fetch/stream 抛出而非后端 error 事件时使用)
const buildNetworkErrorInfo = (httpStatus?: number): ChatErrorInfo => {
const buildNetworkErrorInfo = (
httpStatus?: number,
opts?: { errorCode?: string; detail?: string },
): ChatErrorInfo => {
const code = opts?.errorCode || '';
const detail = opts?.detail || '';
if (code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE') {
return {
userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试',
code,
class: 'policy',
actions: ['retry'],
};
}
if (code === 'DOC_DUTY_FORBIDDEN') {
return {
userMessage: detail || '当前账号不是该文档任务的职责人,无法执行',
code,
class: 'auth',
actions: ['retry'],
};
}
if (httpStatus === 401) {
return {
userMessage: '登录已失效,请重新登录后再试',
@@ -2053,9 +2075,17 @@ export default function App() {
};
}
if (httpStatus === 403) {
if (code === 'ERR_FORBIDDEN' || detail.includes('无权访问该项目')) {
return {
userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份',
code: code || 'ERR_FORBIDDEN',
class: 'auth',
actions: ['retry'],
};
}
return {
userMessage: '无权访问该会话或项目,请确认登录账号与会话归属',
code: 'SESSION_FORBIDDEN',
userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段',
code: code || 'ERR_FORBIDDEN',
class: 'auth',
actions: ['retry'],
};
@@ -2283,7 +2313,13 @@ export default function App() {
// #region agent log
fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'App.tsx:runStreamAttempt.catch',message:'stream fetch failed',data:{httpStatus,sessionId:streamSessionId,userId:currentUser.id},timestamp:Date.now(),hypothesisId:'H1'})}).catch(()=>{});
// #endregion
failOrRetry(buildNetworkErrorInfo(httpStatus));
const errMeta = _err as { errorCode?: string; detail?: string; message?: string };
failOrRetry(
buildNetworkErrorInfo(httpStatus, {
errorCode: errMeta.errorCode,
detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '),
}),
);
});
};
@@ -2456,7 +2492,13 @@ export default function App() {
// #region agent log
fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'App.tsx:runTaskAttempt.catch',message:'task run failed',data:{httpStatus,taskProjectId:task.projectId,sessionId:task.sessionId,taskKey:task.taskKey},timestamp:Date.now(),hypothesisId:'H5'})}).catch(()=>{});
// #endregion
failOrRetry(buildNetworkErrorInfo(httpStatus));
const errMeta = _err as { errorCode?: string; detail?: string; message?: string };
failOrRetry(
buildNetworkErrorInfo(httpStatus, {
errorCode: errMeta.errorCode,
detail: errMeta.detail || errMeta.message?.split(': ').slice(1).join(': '),
}),
);
});
};
@@ -2665,25 +2707,7 @@ export default function App() {
(text.includes('通过') || text.includes('同意')) &&
((docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') ||
(docAST.lifecycleState === 'approving' && currentDocRole === 'approver'));
const matchedTemplate = currentProjectDeliverables.find((d) => {
if (!d.isTemplateOnly) return false;
if (text.includes('需求') || text.includes('摸底') || text.includes('用户')) {
return d.taskKey === 'intake' || d.taskKey === 'user_requirements';
}
if (text.includes('调研') || text.includes('竞品')) {
return d.taskKey === 'research';
}
if (text.includes('可行性') || text.includes('注册') || text.includes('报告')) {
return d.taskKey === 'feasibility_reg' || d.taskKey === 'feasibility_dev';
}
if (text.includes('设计') || text.includes('规格') || text.includes('架构')) {
return d.taskKey === 'prelim_design';
}
if (text.includes('报价') || text.includes('工时') || text.includes('预算')) {
return d.taskKey === 'cdo_quote';
}
return false;
});
const matchedTemplate = matchTemplateDeliverable(text, currentProjectDeliverables);
const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut;
// P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复)
@@ -2783,7 +2807,10 @@ export default function App() {
const newMeta: DeliverableItem = {
docId: newDocId,
projectId: sessions.find((s) => s.sessionId === activeSessionId)?.projectId || 1,
projectId: resolveSessionProjectId(
sessions.find((s) => s.sessionId === activeSessionId),
currentProjectId,
) || 1,
taskKey: 'custom_draft',
title: docTitle,
stage: 'V0.5',
+7 -2
View File
@@ -26,15 +26,20 @@ export async function createTaskRun(body: CreateTaskRunBody): Promise<CreateTask
// #endregion
if (!res.ok) {
let detail = '';
let errorCode = '';
try {
const payload = (await res.json()) as { message?: string; error?: string; error_code?: string };
detail = payload.message || payload.error || payload.error_code || '';
errorCode = payload.error || payload.error_code || '';
detail = payload.message || errorCode || '';
} catch {
// ignore non-json body
}
// #region agent log
fetch('http://localhost:7284/ingest/86170f40-1203-4130-a0c7-f6e39ba00464',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'6c6caa'},body:JSON.stringify({sessionId:'6c6caa',location:'taskRuns.ts:createTaskRun',message:'create task run error body',data:{status:res.status,errorCode,detail,projectId:body.project_id,taskKey:body.task_key},timestamp:Date.now(),hypothesisId:'H6'})}).catch(()=>{});
// #endregion
throw Object.assign(
new Error(`create task run failed: HTTP ${res.status}${detail ? `: ${detail}` : ''}`),
{ httpStatus: res.status },
{ httpStatus: res.status, errorCode, detail },
);
}
return res.json();
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { matchTemplateDeliverable } from './templateMatch';
import { DeliverableItem } from '../types/workbench';
const base: Omit<DeliverableItem, 'taskKey' | 'title'> = {
docId: 'doc-1',
projectId: 143,
stage: 'V0.1',
category: 'workflow',
creatorId: 9,
creatorName: 'admin',
isFixedWorkflow: true,
isTemplateOnly: true,
lifecycleState: 'drafting',
isClaimed: false,
author: '',
reviewer: '',
approver: '',
updatedAt: '',
};
const pool: DeliverableItem[] = [
{ ...base, docId: 'doc-intake', taskKey: 'user_requirements', title: '用户需求表', stage: 'V0.5' },
{ ...base, docId: 'doc-urs', taskKey: 'intake', title: '用户初始需求表', stage: 'V0.1' },
];
test('matchTemplateDeliverable prefers intake for 初始需求表', () => {
const hit = matchTemplateDeliverable('根据上传的文档,开始编写用户初始需求表', pool);
assert.equal(hit?.taskKey, 'intake');
});
test('matchTemplateDeliverable selects user_requirements for explicit URS', () => {
const hit = matchTemplateDeliverable('请编制用户需求表 URS', pool);
assert.equal(hit?.taskKey, 'user_requirements');
});
+57
View File
@@ -0,0 +1,57 @@
import { DeliverableItem } from '../types/workbench';
function templatePool(deliverables: DeliverableItem[]): DeliverableItem[] {
return deliverables.filter((d) => d.isTemplateOnly);
}
/** 在 intake / user_requirements 等同语候选里按文案与阶段选择正确任务。 */
function pickRequirementsTask(text: string, pool: DeliverableItem[]): DeliverableItem | undefined {
const intake = pool.find((d) => d.taskKey === 'intake');
const userReq = pool.find((d) => d.taskKey === 'user_requirements');
if (!intake && !userReq) return undefined;
if (intake && !userReq) return intake;
if (!intake && userReq) return userReq;
const wantsUrs =
text.includes('用户需求表') ||
text.includes('URS') ||
(text.includes('用户需求') && !text.includes('初始'));
if (wantsUrs) return userReq;
const wantsIntake =
text.includes('初始需求') ||
text.includes('需求摸底') ||
text.includes('摸底') ||
text.includes('初始需求表');
if (wantsIntake) return intake;
// 含糊表述默认 V0.1 摸底表,避免误触 V0.5 用户需求表导致阶段门禁 403
return intake;
}
/** 根据用户输入在模板交付物中解析应触发的文档任务。 */
export function matchTemplateDeliverable(
text: string,
deliverables: DeliverableItem[],
): DeliverableItem | undefined {
const pool = templatePool(deliverables);
if (text.includes('需求') || text.includes('摸底') || text.includes('用户')) {
return pickRequirementsTask(text, pool);
}
if (text.includes('调研') || text.includes('竞品')) {
return pool.find((d) => d.taskKey === 'research');
}
if (text.includes('可行性') || text.includes('注册') || text.includes('报告')) {
return pool.find(
(d) => d.taskKey === 'feasibility_reg' || d.taskKey === 'feasibility_dev',
);
}
if (text.includes('设计') || text.includes('规格') || text.includes('架构')) {
return pool.find((d) => d.taskKey === 'prelim_design');
}
if (text.includes('报价') || text.includes('工时') || text.includes('预算')) {
return pool.find((d) => d.taskKey === 'cdo_quote');
}
return undefined;
}