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:
+52
-25
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user