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

**触发确认链路**(用户口述五步的第 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
@@ -3,7 +3,7 @@
*/
import type { TriggerConfig } from '@contracts';
import type { EventScope, FireOutcome, TriggerService } from '../../contract.ts';
import type { Clock, DeadLetterSink, IdGenerator, LedgerScanner, ProcessGateway, Sfeel, TriggerStore } from '../../port.ts';
import type { Clock, ConfirmDesk, DeadLetterSink, IdGenerator, LedgerScanner, ProcessGateway, Sfeel, TriggerStore } from '../../port.ts';
import {
applyOffset,
assembleInput,
@@ -19,6 +19,7 @@ export type TriggerServiceDeps = {
readonly process: ProcessGateway;
readonly sfeel: Sfeel;
readonly deadLetters: DeadLetterSink;
readonly confirmDesk: ConfirmDesk;
readonly ledger: LedgerScanner;
readonly clock: Clock;
readonly ids: IdGenerator;
@@ -42,6 +43,46 @@ export function createTriggerService(deps: TriggerServiceDeps): TriggerService {
});
return { triggerId: config.id, skipped: 'input-missing', missing: assembled.fields };
}
/*
* 触发确认链路(OS 用户口述五步的第 4/5 步,2026-07-30):
* 勾了「需人工确认」,命中**不等于起单** —— 装配好的入参存成待确认单,
* 带来件内容的系统消息送到指定人工作台;他点「确认起单」流程才起。
* 登记触发不走这条:登记那一下本身就是人在确认。
*/
if (config.confirm.required && source.kind !== 'registration') {
const staffId = config.confirm.staffId;
if (staffId === undefined) {
// 勾了要确认却没指定谁 —— 这是配置错误,如实入死信,不静默丢、也不擅自直起
await deps.deadLetters.report({
triggerId: config.id,
reason: '勾了「需人工确认」但没有指定确认人 —— 去运营中心 · 工作流补上',
detail: { subject },
});
return { triggerId: config.id, skipped: 'no-confirmer' };
}
const pendingId = deps.ids.next();
// 来件预览只收字符串字段 —— 消息上带内容,确认的人不用回头翻邮箱
const preview = Object.fromEntries(
(['from', 'subject', 'body'] as const).flatMap((k) => {
const v = scope[k];
return typeof v === 'string' ? [[k, v.slice(0, 2000)]] : [];
}),
);
await deps.store.createPending({
id: pendingId,
triggerId: config.id,
packageId: config.processPackageId,
staffId,
subject,
data: assembled.data,
source,
preview,
createdAt: deps.clock.now().toISOString(),
});
await deps.confirmDesk.request({ pendingId, staffId, triggerName: config.name, subject, preview });
return { triggerId: config.id, pendingId };
}
const workflow = await deps.process.start({
packageId: config.processPackageId,
subject,
@@ -135,5 +176,47 @@ export function createTriggerService(deps: TriggerServiceDeps): TriggerService {
}
return outcomes;
},
async setConfirm(id, confirm) {
await deps.store.setConfirm(id, confirm);
},
async confirmPending(pendingId, by) {
const pending = await deps.store.getPending(pendingId);
if (pending === undefined) throw new Error(`无此待确认单:${pendingId}`);
if (pending.consumedAt !== undefined) {
if (pending.workflowId === undefined) throw new Error('这一票正在被确认 —— 稍等两秒再看');
return { workflowId: pending.workflowId, alreadyStarted: true };
}
/*
* 先认领后起单:consumed_at 空着才认得到,两人同时点确认只有一个赢 ——
* 绝不起两条流。起单失败就把认领退回去,这一票还能再确认。
*/
const won = await deps.store.claimPending(pendingId, deps.clock.now());
if (!won) {
const again = await deps.store.getPending(pendingId);
if (again?.workflowId === undefined) throw new Error('这一票正在被确认 —— 稍等两秒再看');
return { workflowId: again.workflowId, alreadyStarted: true };
}
try {
const workflow = await deps.process.start({
packageId: pending.packageId,
subject: pending.subject,
// 确认人记进来源:这条流是谁点头放行的,留痕上要看得到
source: { ...pending.source, by },
data: pending.data,
});
await deps.store.setPendingWorkflow(pendingId, workflow.id);
await deps.store.markFired(pending.triggerId, deps.clock.now());
return { workflowId: workflow.id, alreadyStarted: false };
} catch (error) {
await deps.store.releasePending(pendingId);
throw error;
}
},
async pendingByIds(ids) {
return deps.store.pendingByIds(ids);
},
};
}