Why: The base gate (cmdbus.RequiresProjectWorkDecision) refuses rdm.review.doc.approve/reject/final unless the request carries a committed conversation source as top-level conversationId and anchorMessageId. The workspace sent bare params, so the decision buttons were refused in the real product. Evidence: vitest 39 files / 441 tests passed (22 new in test/document-decision.test.ts); npx tsc --noEmit clean; npm run build ok; check-knip unused exports 51 <= 51; e2e/document-renderer.playwright.ts type-checks and lists 13 tests. Request shape from pins/agent-system-backend: internal/server/server.go:1276-1304 (body decode), internal/cmdbus/cmdbus.go:109-134 and 385, internal/app/app.go:646-665 (preflight), server.go:1010-1150 (message POST returns the committed message). Impact: approve/reject/final first post a Chinese decision message to the host project's 文档审签记录 conversation (reused, or created), then dispatch with its id as the anchor (entrance button). Identity is never sent. Any failing step shows a readable refusal and nothing is dispatched. Double-click is ignored while an action is in flight, and retries reuse the operation id so the server returns the same source message. rdmCommand takes an optional source; rdm.render.* is unchanged. The e2e spec drives §11-5c through the workspace only and asserts the anchored, committed receipt. Not run against a live backend or browser here. Refs: unified renderer plan Task 11; RDM cmdbus gate (pins/agent-system-backend internal/cmdbus/cmdbus.go:109-134); live-verify sequence /tmp/live-verify/run.sh lines 56-70 and 140-146. Co-authored-by: Cursor <cursoragent@cursor.com>
125 lines
5.8 KiB
TypeScript
125 lines
5.8 KiB
TypeScript
// 统一渲染 Task 11(审签来源):整文审签的正式命令必须锚定一条已提交的对话消息(基座来源门禁)。
|
||
// 流程:宿主项目的审签记录对话 → 提交一条中文审签说明(用户消息,幂等编号 = 动作编号)→ 以该消息为锚点分发命令。
|
||
// 任一步失败都可读地拒绝且绝不分发命令;身份只由服务端认证主体决定,载荷不含任何身份字段。
|
||
import type { Conversation, Message, Project } from './contracts'
|
||
import { describeRenderFailure, type DecisionKind } from './documentRenderPresentation'
|
||
import { rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
|
||
|
||
export const DECISION_RECORD_TITLE = '文档审签记录'
|
||
const DECISION_ENTRANCE: CommandSource['entrance'] = 'button'
|
||
const CONVERSATION_STAGE = '审签记录对话'
|
||
const SOURCE_STAGE = '审签来源消息'
|
||
|
||
/** 只需要的会话端口:调用方传入共享的 api 对象,不另起一层 API。 */
|
||
export interface DecisionPort {
|
||
listConversations: (projectId: string) => Promise<Conversation[]>
|
||
createConversation: (projectId: string, title?: string) => Promise<Conversation>
|
||
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) => Promise<{ message?: Message; command?: string }>
|
||
rdmCommand: (id: string, params: Record<string, unknown>, source?: CommandSource) => Promise<unknown>
|
||
}
|
||
|
||
export interface DecisionRequest {
|
||
host: Project | undefined
|
||
/** 同一用户动作的编号:重试复用,服务端据此幂等返回同一条来源消息。 */
|
||
operationId: string
|
||
decision: DecisionKind
|
||
doc: { id: string; title: string }
|
||
round: number
|
||
note: string
|
||
command: string
|
||
params: Record<string, unknown>
|
||
}
|
||
|
||
function decisionLabel(kind: DecisionKind): string {
|
||
switch (kind) {
|
||
case 'approve': return '审核通过'
|
||
case 'reject': return '驳回'
|
||
case 'final': return '终批'
|
||
default: {
|
||
const exhaustive: never = kind
|
||
throw new Error(`unknown decision kind: ${String(exhaustive)}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 审签来源正文(中文):决定、文档、轮次与说明/原因;不含任何身份字段,首字符不为 “/” 以免被当作命令。 */
|
||
export function decisionSourceText(input: Pick<DecisionRequest, 'decision' | 'doc' | 'round' | 'note'>): string {
|
||
const doc = input.doc.title ? `${input.doc.title}(${input.doc.id})` : input.doc.id
|
||
const noteLabel = input.decision === 'reject' ? '驳回原因' : '说明'
|
||
return [
|
||
`审签决定:${decisionLabel(input.decision)}`,
|
||
`文档:${doc}`,
|
||
`轮次:第 ${input.round} 轮`,
|
||
`${noteLabel}:${input.note.trim() || '无'}`,
|
||
'来源:文档渲染工作区',
|
||
].join('\n')
|
||
}
|
||
|
||
/** 研发项目 → 其宿主对话项目(settings 中绑定的 rdm.projectId 相同者)。 */
|
||
export function hostProjectFor(projects: Project[], rdmProjectId: string): Project | undefined {
|
||
return projects.find(p => {
|
||
try { return rdmCommandProjectId(p) === rdmProjectId } catch { return false }
|
||
})
|
||
}
|
||
|
||
function refusal(stage: string, detail: string): Error {
|
||
return new Error(`${stage}未能提交,审签未执行:${detail}`)
|
||
}
|
||
|
||
function stepFailure(stage: string, err: unknown): Error {
|
||
const raw = err instanceof Error ? err.message : String(err ?? '')
|
||
if (/\b403\b/.test(raw)) return refusal(stage, '当前账号无权在该对话中记录审签')
|
||
if (/\b409\b/.test(raw)) return refusal(stage, '会话正在处理其他任务,请稍后重试')
|
||
if (/\b429\b/.test(raw)) return refusal(stage, '会话排队已满,请稍后重试')
|
||
return refusal(stage, describeRenderFailure(err).message)
|
||
}
|
||
|
||
/** 记录对话:复用宿主项目中标题为审签记录、未归档的对话;没有则新建。 */
|
||
async function recordConversation(port: DecisionPort, projectId: string): Promise<Conversation> {
|
||
let list: Conversation[]
|
||
try {
|
||
list = await port.listConversations(projectId)
|
||
} catch (e) {
|
||
throw stepFailure(CONVERSATION_STAGE, e)
|
||
}
|
||
const existing = list.find(c => c.title === DECISION_RECORD_TITLE && !c.archived)
|
||
if (existing) return existing
|
||
try {
|
||
return await port.createConversation(projectId, DECISION_RECORD_TITLE)
|
||
} catch (e) {
|
||
throw stepFailure(CONVERSATION_STAGE, e)
|
||
}
|
||
}
|
||
|
||
/** 提交来源消息:必须是已提交的同会话用户消息;被当作命令路由的说明不算来源。 */
|
||
async function recordSource(port: DecisionPort, convId: string, req: DecisionRequest): Promise<string> {
|
||
let out: { message?: Message; command?: string }
|
||
try {
|
||
out = await port.sendMessage(convId, decisionSourceText(req), [], `decision:${req.operationId}`)
|
||
} catch (e) {
|
||
throw stepFailure(SOURCE_STAGE, e)
|
||
}
|
||
if (out.command) throw refusal(SOURCE_STAGE, '审签说明被识别为命令,未作为来源记录')
|
||
const msg = out.message
|
||
if (!msg?.id || msg.role !== 'user' || msg.conversationId !== convId) {
|
||
throw refusal(SOURCE_STAGE, '服务端未返回已提交的对话消息')
|
||
}
|
||
return msg.id
|
||
}
|
||
|
||
/**
|
||
* 整文审签的唯一写路径:先记录来源,再以来源消息为锚点分发命令。
|
||
* 宿主项目缺失、项目未就绪、任一记录步骤失败时,命令一律不分发。
|
||
*/
|
||
export async function commitDecision(port: DecisionPort, req: DecisionRequest): Promise<unknown> {
|
||
const host = req.host
|
||
if (!host) throw new Error('未找到该研发项目对应的对话项目,请刷新项目列表后重试;审签未执行')
|
||
const gate = rdmProjectCommandError(host, req.command)
|
||
if (gate) throw new Error(`${gate};审签未执行`)
|
||
const conversation = await recordConversation(port, host.id)
|
||
const anchorMessageId = await recordSource(port, conversation.id, req)
|
||
return port.rdmCommand(req.command, req.params, {
|
||
conversationId: conversation.id, anchorMessageId, entrance: DECISION_ENTRANCE,
|
||
})
|
||
}
|