Archived
**触发确认链路**(用户口述五步的第 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
139 lines
5.0 KiB
TypeScript
139 lines
5.0 KiB
TypeScript
/**
|
|
* trigger 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
|
|
*/
|
|
import { eq, inArray, isNull, and } from 'drizzle-orm';
|
|
import type { TriggerConfig } from '@contracts';
|
|
import { triggerConfigSchema } from '@contracts';
|
|
import type { Db } from '@shared/db.ts';
|
|
import type { PendingFire } from '../../contract.ts';
|
|
import type { TriggerStore } from '../../port.ts';
|
|
import { triggerClaimTable, triggerPendingTable, triggerTable } from './trigger-schema.ts';
|
|
|
|
type Row = typeof triggerTable.$inferSelect;
|
|
|
|
function toConfig(row: Row): TriggerConfig {
|
|
return triggerConfigSchema.parse({
|
|
id: row.id,
|
|
name: row.name,
|
|
enabled: row.enabled,
|
|
processPackageId: row.processPackageId,
|
|
mapping: row.mapping,
|
|
spec: row.spec,
|
|
confirm: { required: row.confirmRequired, ...(row.confirmStaffId === null ? {} : { staffId: row.confirmStaffId }) },
|
|
});
|
|
}
|
|
|
|
type PendingRow = typeof triggerPendingTable.$inferSelect;
|
|
|
|
function toPending(row: PendingRow): PendingFire {
|
|
return {
|
|
id: row.id,
|
|
triggerId: row.triggerId,
|
|
packageId: row.packageId,
|
|
staffId: row.staffId,
|
|
subject: row.subject,
|
|
data: row.data as Record<string, unknown>,
|
|
source: row.source as PendingFire['source'],
|
|
preview: row.preview as Record<string, string>,
|
|
createdAt: row.createdAt.toISOString(),
|
|
...(row.consumedAt === null ? {} : { consumedAt: row.consumedAt.toISOString() }),
|
|
...(row.workflowId === null ? {} : { workflowId: row.workflowId }),
|
|
};
|
|
}
|
|
|
|
export function createTriggerStore(db: Db): TriggerStore {
|
|
return {
|
|
async list() {
|
|
const rows = await db.select().from(triggerTable).orderBy(triggerTable.name);
|
|
return rows.map(toConfig);
|
|
},
|
|
async get(id) {
|
|
const rows = await db.select().from(triggerTable).where(eq(triggerTable.id, id)).limit(1);
|
|
return rows[0] === undefined ? undefined : toConfig(rows[0]);
|
|
},
|
|
async upsert(config) {
|
|
// 设计面的 upsert **不含 confirm 两列** —— 运营的勾只归 setConfirm 动
|
|
const values = {
|
|
id: config.id,
|
|
name: config.name,
|
|
enabled: config.enabled,
|
|
processPackageId: config.processPackageId,
|
|
mapping: config.mapping,
|
|
spec: config.spec,
|
|
};
|
|
const [row] = await db
|
|
.insert(triggerTable)
|
|
.values(values)
|
|
.onConflictDoUpdate({ target: triggerTable.id, set: values })
|
|
.returning();
|
|
if (row === undefined) throw new Error('触发器写入未返回行');
|
|
return toConfig(row);
|
|
},
|
|
async setEnabled(id, enabled) {
|
|
await db.update(triggerTable).set({ enabled }).where(eq(triggerTable.id, id));
|
|
},
|
|
async remove(id) {
|
|
await db.delete(triggerTable).where(eq(triggerTable.id, id));
|
|
},
|
|
async claim(key) {
|
|
const inserted = await db
|
|
.insert(triggerClaimTable)
|
|
.values({ key })
|
|
.onConflictDoNothing()
|
|
.returning({ key: triggerClaimTable.key });
|
|
return inserted.length > 0;
|
|
},
|
|
async lastFiredAt(id) {
|
|
const rows = await db.select({ at: triggerTable.lastFiredAt }).from(triggerTable).where(eq(triggerTable.id, id)).limit(1);
|
|
return rows[0]?.at ?? undefined;
|
|
},
|
|
async markFired(id, at) {
|
|
await db.update(triggerTable).set({ lastFiredAt: at }).where(eq(triggerTable.id, id));
|
|
},
|
|
|
|
async setConfirm(id, confirm) {
|
|
await db
|
|
.update(triggerTable)
|
|
.set({ confirmRequired: confirm.required, confirmStaffId: confirm.staffId ?? null })
|
|
.where(eq(triggerTable.id, id));
|
|
},
|
|
|
|
async createPending(pending) {
|
|
await db.insert(triggerPendingTable).values({
|
|
id: pending.id,
|
|
triggerId: pending.triggerId,
|
|
packageId: pending.packageId,
|
|
staffId: pending.staffId,
|
|
subject: pending.subject,
|
|
data: pending.data,
|
|
source: pending.source,
|
|
preview: pending.preview,
|
|
});
|
|
},
|
|
async getPending(id) {
|
|
const rows = await db.select().from(triggerPendingTable).where(eq(triggerPendingTable.id, id)).limit(1);
|
|
return rows[0] === undefined ? undefined : toPending(rows[0]);
|
|
},
|
|
async pendingByIds(ids) {
|
|
if (ids.length === 0) return [];
|
|
const rows = await db.select().from(triggerPendingTable).where(inArray(triggerPendingTable.id, [...ids]));
|
|
return rows.map(toPending);
|
|
},
|
|
async claimPending(id, at) {
|
|
// 只认 consumed_at 还空着的 —— 两人同时点确认,数据库替我们选出唯一赢家
|
|
const rows = await db
|
|
.update(triggerPendingTable)
|
|
.set({ consumedAt: at })
|
|
.where(and(eq(triggerPendingTable.id, id), isNull(triggerPendingTable.consumedAt)))
|
|
.returning({ id: triggerPendingTable.id });
|
|
return rows.length > 0;
|
|
},
|
|
async setPendingWorkflow(id, workflowId) {
|
|
await db.update(triggerPendingTable).set({ workflowId }).where(eq(triggerPendingTable.id, id));
|
|
},
|
|
async releasePending(id) {
|
|
await db.update(triggerPendingTable).set({ consumedAt: null }).where(eq(triggerPendingTable.id, id));
|
|
},
|
|
};
|
|
}
|