三面拆分⑬:触发确认链路 + 运营中心「工作流」区 —— 设计只管设计,运营管细节

**触发确认链路**(用户口述五步的第 4/5 步):
  接收邮件 → 分类识别 → 命中 RFQ 事件 → ④(可选,运营勾)系统消息到指定人
  工作台(带邮件内容)→ ⑤ 工作台点「确认起单」,流程才起。
  · 命中不等于必起:勾了「需人工确认」,装配好的入参存成待确认单
    (sys.trigger_pending,migration 0019),ConfirmDesk 端口把带来件内容的
    系统消息送指定人(eventRef 挂票号,消息列表就地贴确认状态与那枚钮)。
  · 先认领后起单:consumed_at 空才认得到 —— 两人同时点、一人点两次,都只起一条流;
    起单失败退回认领,不留死票。确认人记进 source.by,留痕可查。
  · 登记触发不走确认(登记那一下本身就是人在确认);勾了没指定人 = 配置错误入死信。
  · 4 条单测 + 全链实测:投信 → triggersHit=1 且零直起 → AI 工作台收到确认消息
    → 确认起单 → 二次确认幂等回同一流号 → source.by = AI。

**运营中心重排**(区名即运行时:工作流是流程包的运行时,任务是活动的运行时):
  · 新「工作流」区:在途看板(从任务区迁入)+ 运行配置 —— 每包的触发器
    起单开关 / 确认打勾 / 确认人,和节点负责人(sys.node_owner)。
  · 负责人三层:显式指派 > 运营配置 > 轮值 —— 合并点在 process.start,
    复用既有派发链一行不改。
  · 任务区瘦身成活动运行时(任务/审批/需处置);知识库区改名「数据」
    (与设计中心的知识库解开同名);系统区只剩通道实例。
  · 设计中心触发器详情页摘掉启停按钮 —— 设计只管设计,启停/确认全在运营。
  · 旧深链有改嫁:tasks/board → runs/board,tasks/trg → runs/conf。

reset-runs 补 sys.trigger_pending(待确认单是运行痕 —— 和上回漏 trace_span 同类洞)。
邮件分类 Agent(第 ② 步)现由抽键正则 + S-FEEL 代位,升级路径记进 debt.md。
产品设计.md 运营章、trigger/process 两份 CONTRACT.md 同步。

五闸绿;契约快照随本提交入库。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-29 13:09:16 +08:00
co-authored by Claude Opus 5
parent 70b261d067
commit f9b2c07731
45 changed files with 1176 additions and 115 deletions
+2
View File
@@ -37,6 +37,8 @@ const RUN_TABLES = [
'sys.outbound_mail',
'sys.outbound_ref',
'sys.trigger_claim',
// 待确认单是运行痕:命中未确认的那一票随运行数据一起清 —— 配置(trigger/node_owner)不动
'sys.trigger_pending',
'sys.report',
'sys.replay_model',
'sys.replay_tool',
+48
View File
@@ -9,6 +9,7 @@ import {
ATTRIBUTION_SIDE_LABEL,
answerMessageRoute,
chatCardReplySchema,
confirmTriggerPendingRoute,
listMessagesRoute,
markMessageReadRoute,
listTasksRoute,
@@ -308,6 +309,15 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
return c.json(body, 401);
}
const messages = await container.message.inbox(staff.id);
/*
* 待确认起单(触发确认链路):`eventRef` 挂着待确认单号的消息,
* 就地贴出那一票的状态 —— 界面靠它决定「确认起单」钮是活的还是已收线。
*/
const pendingIds = messages
.map((m) => m.eventRef)
.filter((r): r is string => r !== undefined && r.startsWith('trigger-pending:'))
.map((r) => r.slice('trigger-pending:'.length));
const pendings = new Map((await container.trigger.pendingByIds(pendingIds)).map((x) => [x.id, x]));
return c.json({
messages: messages.map((m) => ({
id: m.id,
@@ -316,6 +326,19 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
body: m.body,
at: m.at,
read: m.readAt !== undefined,
...(() => {
const ref = m.eventRef;
if (ref === undefined || !ref.startsWith('trigger-pending:')) return {};
const pending = pendings.get(ref.slice('trigger-pending:'.length));
if (pending === undefined) return {};
return {
confirm: {
pendingId: pending.id,
consumed: pending.consumedAt !== undefined,
...(pending.workflowId === undefined ? {} : { workflowId: pending.workflowId }),
},
};
})(),
// 跨任务征答卡随消息一并出:就地作答,不必跳进别人的任务
...(m.card === undefined
? {}
@@ -334,6 +357,31 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
});
});
/** 确认起单(触发确认链路第 5 步)—— 幂等;只有被点名的确认人按得动 */
app.post(confirmTriggerPendingRoute.path, async (c) => {
const staff = await me(c.get('account'));
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
}
const id = c.req.param('id');
const pending = (await container.trigger.pendingByIds([id]))[0];
if (pending === undefined) {
const body: ApiError = { code: 'not-found', message: `无此待确认单:${id}` };
return c.json(body, 404);
}
if (pending.staffId !== staff.id) {
const body: ApiError = { code: 'forbidden', message: '这一票点名的确认人不是你' };
return c.json(body, 403);
}
try {
return c.json(await container.trigger.confirmPending(id, staff.id));
} catch (error) {
const body: ApiError = { code: 'validation-failed', message: error instanceof Error ? error.message : String(error) };
return c.json(body, 400);
}
});
app.post(markMessageReadRoute.path, async (c) => {
const staff = await me(c.get('account'));
if (staff === undefined) {
+46
View File
@@ -35,6 +35,9 @@ import {
resumeTaskRoute,
staffIdSchema,
taskIdSchema,
setNodeOwnerRoute,
setTriggerConfirmRoute,
listNodeOwnersRoute,
toggleTriggerRoute,
upsertTriggerRoute,
validateProcessRoute,
@@ -549,6 +552,49 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
return c.json({ ok: true });
});
/**
* 触发器的**运营配置**:要不要人工确认、谁来确认 —— 与设计面的 upsert 分口,
* 设计动规则不碰这两项,运营动这两项不碰规则。
*/
app.post(setTriggerConfirmRoute.path, async (c) => {
const denied = await governTrigger(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = setTriggerConfirmRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
return c.json(body, 400);
}
if (parsed.data.required && parsed.data.staffId === undefined) {
const body: ApiError = { code: 'validation-failed', message: '勾了「需人工确认」就得指定确认人 —— 不然命中的单没人点头,全进死信' };
return c.json(body, 400);
}
await container.trigger.setConfirm(c.req.param('id'), {
required: parsed.data.required,
...(parsed.data.staffId === undefined ? {} : { staffId: parsed.data.staffId }),
});
return c.json({ ok: true });
});
/** 节点负责人(运营配置):显式指派 > 这里 > 轮值 */
app.get(listNodeOwnersRoute.path, async (c) => {
const denied = await readProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const owners = await container.process.nodeOwners(c.req.param('id'));
return c.json({ owners: Object.entries(owners).map(([nodeId, staffId]) => ({ nodeId, staffId })) });
});
app.put(setNodeOwnerRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = setNodeOwnerRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
return c.json(body, 400);
}
await container.process.setNodeOwner(c.req.param('id'), c.req.param('nodeId'), parsed.data.staffId ?? undefined);
return c.json({ ok: true });
});
/**
* 调起规则的定义面(设计中心)—— 新建 / 改规则 / 改起单装配。
* 启停不走这里(那是运营的事,见 toggle);改定义同样不动任何流程包版本。
+20
View File
@@ -945,6 +945,26 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
},
},
sfeel,
/*
* 确认台:命中但勾了「需人工确认」—— 带来件内容的系统消息送到指定人工作台,
* `eventRef` 挂待确认单号,工作台消息列表凭它贴出「确认起单」那枚钮。
*/
confirmDesk: {
request: async (input) => {
const lines = [
`触发器「${input.triggerName}」命中一件来件,按运营配置须你确认后才起单。`,
input.preview['from'] === undefined ? undefined : `发件人:${input.preview['from']}`,
input.preview['subject'] === undefined ? undefined : `主题:${input.preview['subject']}`,
input.preview['body'] === undefined ? undefined : `\n${input.preview['body'].slice(0, 600)}`,
].filter((l): l is string => l !== undefined);
await deps.message.notify({
staffId: input.staffId as never,
title: `确认起单:${input.subject}`,
body: lines.join('\n'),
eventRef: `trigger-pending:${input.pendingId}`,
});
},
},
deadLetters: {
// 起单失败入死信 —— 落日志 + 送治理台的消息要素,不静默丢弃
report: async (input) => {