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
This commit is contained in:
@@ -54,6 +54,8 @@ export type ProcessService = {
|
||||
getWorkflow(id: string): Promise<WorkflowRecord>;
|
||||
listWorkflows(limit?: number): Promise<WorkflowRecord[]>;
|
||||
instancesOf(workflowId: string): Promise<NodeInstance[]>;
|
||||
nodeOwners(packageId: string): Promise<Record<string, string>>;
|
||||
setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise<void>;
|
||||
/** 任务终态到达:完成即继续流转;非完成即走声明的失败走向 */
|
||||
onTaskTerminal(taskId: string, terminal: TerminalState, ports: Record<string, Record<string, unknown>>): Promise<WorkflowState | undefined>;
|
||||
applyFailure(workflowId: string, nodeId: string, instance: number, node: Extract<ProcessNode, {
|
||||
|
||||
@@ -83,6 +83,8 @@ export type ProcessStore = {
|
||||
nodeId: string;
|
||||
instance: number;
|
||||
} | undefined>;
|
||||
nodeOwners(packageId: string): Promise<Record<string, string>>;
|
||||
setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise<void>;
|
||||
};
|
||||
/** 活动契约面 —— app 注入 `activity-registry`(L4):编排校验与起单都要它 */
|
||||
export type ActivityDirectory = {
|
||||
|
||||
+34
-1
@@ -15,9 +15,30 @@ export declare class TriggerInputMissingError extends Error {
|
||||
export type FireOutcome = {
|
||||
readonly triggerId: string;
|
||||
readonly workflowId?: string;
|
||||
readonly skipped?: 'duplicate' | 'disabled' | 'input-missing';
|
||||
/** 命中但勾了「需人工确认」:没起单,等指定人点头 —— 这一票的号 */
|
||||
readonly pendingId?: string;
|
||||
readonly skipped?: 'duplicate' | 'disabled' | 'input-missing' | 'no-confirmer';
|
||||
readonly missing?: readonly string[];
|
||||
};
|
||||
/** 待确认单:装配好的一次起单,等指定人点「确认起单」 */
|
||||
export type PendingFire = {
|
||||
readonly id: string;
|
||||
readonly triggerId: string;
|
||||
readonly packageId: string;
|
||||
/** 确认人快照 —— 确认路由按它认门 */
|
||||
readonly staffId: string;
|
||||
readonly subject: string;
|
||||
readonly data: Record<string, unknown>;
|
||||
readonly source: {
|
||||
readonly kind: 'periodic' | 'event' | 'registration';
|
||||
readonly ref: string;
|
||||
readonly by?: string;
|
||||
};
|
||||
readonly preview: Readonly<Record<string, string>>;
|
||||
readonly createdAt: string;
|
||||
readonly consumedAt?: string;
|
||||
readonly workflowId?: string;
|
||||
};
|
||||
export type TriggerService = {
|
||||
list(): Promise<TriggerConfig[]>;
|
||||
upsert(config: TriggerConfig): Promise<TriggerConfig>;
|
||||
@@ -31,4 +52,16 @@ export type TriggerService = {
|
||||
fireByRegistration(triggerId: string, by: string, data: Record<string, unknown>): Promise<FireOutcome>;
|
||||
/** 周期触发的到点扫描(由 pg-boss 定时调起) */
|
||||
sweepPeriodic(now: Date): Promise<FireOutcome[]>;
|
||||
/** 运营口:要不要人工确认、谁来确认 —— 与设计面的 upsert 分口 */
|
||||
setConfirm(id: string, confirm: {
|
||||
required: boolean;
|
||||
staffId?: string;
|
||||
}): Promise<void>;
|
||||
/** 指定人点头 —— 幂等:已被确认过就回那条流的号,绝不起第二条 */
|
||||
confirmPending(pendingId: string, by: string): Promise<{
|
||||
workflowId: string;
|
||||
alreadyStarted: boolean;
|
||||
}>;
|
||||
/** 待确认单批读 —— 工作台消息列表就地贴确认状态用 */
|
||||
pendingByIds(ids: readonly string[]): Promise<PendingFire[]>;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* module: trigger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { TriggerConfig } from '@contracts';
|
||||
import type { PendingFire } from './contract.ts';
|
||||
export type Clock = {
|
||||
now(): Date;
|
||||
};
|
||||
@@ -13,11 +14,37 @@ export type TriggerStore = {
|
||||
get(id: string): Promise<TriggerConfig | undefined>;
|
||||
upsert(config: TriggerConfig): Promise<TriggerConfig>;
|
||||
setEnabled(id: string, enabled: boolean): Promise<void>;
|
||||
/** 运营口:确认配置 —— 设计面的 upsert 不碰这两列 */
|
||||
setConfirm(id: string, confirm: {
|
||||
required: boolean;
|
||||
staffId?: string;
|
||||
}): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
/** 幂等:同一键至多起一单 */
|
||||
claim(key: string): Promise<boolean>;
|
||||
lastFiredAt(id: string): Promise<Date | undefined>;
|
||||
markFired(id: string, at: Date): Promise<void>;
|
||||
createPending(pending: PendingFire): Promise<void>;
|
||||
getPending(id: string): Promise<PendingFire | undefined>;
|
||||
pendingByIds(ids: readonly string[]): Promise<PendingFire[]>;
|
||||
/** 认领:consumed_at 空才认得到 —— 两个人同时点确认,只有一个赢,绝不起两条流 */
|
||||
claimPending(id: string, at: Date): Promise<boolean>;
|
||||
/** 起单成功后把流号写回;起单失败时用 releasePending 把认领退回去 */
|
||||
setPendingWorkflow(id: string, workflowId: string): Promise<void>;
|
||||
releasePending(id: string): Promise<void>;
|
||||
};
|
||||
/**
|
||||
* 确认台 —— app 注入 `message`(L2):命中但要人点头时,
|
||||
* 把带来件内容的系统消息送到指定人的工作台。
|
||||
*/
|
||||
export type ConfirmDesk = {
|
||||
request(input: {
|
||||
pendingId: string;
|
||||
staffId: string;
|
||||
triggerName: string;
|
||||
subject: string;
|
||||
preview: Readonly<Record<string, string>>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
/** 起单 —— app 注入 `process`(L7 同层?否:trigger 在 process 之上同层,经 app 装配) */
|
||||
export type ProcessGateway = {
|
||||
|
||||
@@ -1325,6 +1325,10 @@ export declare const listTriggersRoute: {
|
||||
sfeel: z.ZodString;
|
||||
}, z.core.$strip>], "from">;
|
||||
}, z.core.$strip>>>;
|
||||
confirm: z.ZodDefault<z.ZodObject<{
|
||||
required: z.ZodDefault<z.ZodBoolean>;
|
||||
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||
}, z.core.$strip>>;
|
||||
spec: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"periodic-calendar">;
|
||||
calendar: z.ZodString;
|
||||
@@ -1400,6 +1404,10 @@ export declare const upsertTriggerRoute: {
|
||||
sfeel: z.ZodString;
|
||||
}, z.core.$strip>], "from">;
|
||||
}, z.core.$strip>>>;
|
||||
confirm: z.ZodDefault<z.ZodObject<{
|
||||
required: z.ZodDefault<z.ZodBoolean>;
|
||||
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||
}, z.core.$strip>>;
|
||||
spec: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"periodic-calendar">;
|
||||
calendar: z.ZodString;
|
||||
@@ -1451,6 +1459,10 @@ export declare const upsertTriggerRoute: {
|
||||
sfeel: z.ZodString;
|
||||
}, z.core.$strip>], "from">;
|
||||
}, z.core.$strip>>>;
|
||||
confirm: z.ZodDefault<z.ZodObject<{
|
||||
required: z.ZodDefault<z.ZodBoolean>;
|
||||
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||
}, z.core.$strip>>;
|
||||
spec: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"periodic-calendar">;
|
||||
calendar: z.ZodString;
|
||||
@@ -2123,6 +2135,53 @@ export declare const listApprovalsRoute: {
|
||||
* 起一条流(测试台专用):**带节点指派**把整条流定向到指定账号 ——
|
||||
* 真实任务照旧落到那个人的工作台,测试台只管起单与观测。
|
||||
*/
|
||||
/**
|
||||
* 触发器的**运营配置**:要不要人工确认、谁来确认 —— 与设计面的 upsert 分口,
|
||||
* 设计动规则不碰这两项,运营动这两项不碰规则。
|
||||
*/
|
||||
export declare const setTriggerConfirmRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/triggers/:id/confirm";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly body: z.ZodObject<{
|
||||
required: z.ZodBoolean;
|
||||
staffId: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
ok: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 节点负责人(运营配置):流程包的哪个节点归谁 —— 显式指派 > 这里 > 轮值 */
|
||||
export declare const listNodeOwnersRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/packages/:id/owners";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
owners: z.ZodArray<z.ZodObject<{
|
||||
nodeId: z.ZodString;
|
||||
staffId: z.ZodString;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const setNodeOwnerRoute: {
|
||||
readonly method: "PUT";
|
||||
readonly path: "/api/gov/packages/:id/owners/:nodeId";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
nodeId: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
/** null = 撤掉运营指定,回到轮值 */
|
||||
readonly body: z.ZodObject<{
|
||||
staffId: z.ZodNullable<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
ok: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const testRunRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/testbed/run";
|
||||
|
||||
@@ -2352,6 +2352,11 @@ export declare const systemMessageSchema: z.ZodObject<{
|
||||
rejectedReason: z.ZodOptional<z.ZodString>;
|
||||
supersededBy: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
confirm: z.ZodOptional<z.ZodObject<{
|
||||
pendingId: z.ZodString;
|
||||
consumed: z.ZodBoolean;
|
||||
workflowId: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
export type SystemMessageView = z.infer<typeof systemMessageSchema>;
|
||||
export declare const listMessagesRoute: {
|
||||
@@ -2400,6 +2405,11 @@ export declare const listMessagesRoute: {
|
||||
rejectedReason: z.ZodOptional<z.ZodString>;
|
||||
supersededBy: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
confirm: z.ZodOptional<z.ZodObject<{
|
||||
pendingId: z.ZodString;
|
||||
consumed: z.ZodBoolean;
|
||||
workflowId: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
@@ -2427,6 +2437,18 @@ export declare const answerMessageRoute: {
|
||||
ok: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/** 确认起单 —— 幂等:已被确认过就如实说,并回那条流的号,绝不起第二条 */
|
||||
export declare const confirmTriggerPendingRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/desk/trigger-pending/:id/confirm";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
workflowId: z.ZodString;
|
||||
alreadyStarted: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export type ListMessagesOutput = z.infer<typeof listMessagesRoute.output>;
|
||||
export declare const minuteResolutionSchema: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
|
||||
@@ -480,6 +480,10 @@ export declare const triggerConfigSchema: z.ZodObject<{
|
||||
sfeel: z.ZodString;
|
||||
}, z.core.$strip>], "from">;
|
||||
}, z.core.$strip>>>;
|
||||
confirm: z.ZodDefault<z.ZodObject<{
|
||||
required: z.ZodDefault<z.ZodBoolean>;
|
||||
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||
}, z.core.$strip>>;
|
||||
spec: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||
kind: z.ZodLiteral<"periodic-calendar">;
|
||||
calendar: z.ZodString;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);改定义同样不动任何流程包版本。
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
被依赖: trigger, app(治理台 · 工作流管理)
|
||||
|
||||
不变量(OS 03 / 3.2):
|
||||
- **节点负责人三层**(2026-07-30):起单时合并 —— 显式指派(start 入参,测试台定向)>
|
||||
运营配置(`sys.node_owner`,运营中心 · 工作流里配)> 轮值(员工组 → 全员最闲者先)。
|
||||
运营配置不随包版本 —— 它说的是「这家企业现在这活谁管」,不是流程长什么样。
|
||||
- **编辑即其身**(产品决策 2026-07-28 第四轮):`savePackage` 对已存在的版本
|
||||
**就地写回**(号不变、状态不变);新版本只出自显式新建(`asNew: true`)或
|
||||
新流程首版(插入,`retired` 不生效)。两道底线:
|
||||
|
||||
@@ -58,6 +58,8 @@ function harness(seed: ProcessPackage[], pinned = 0) {
|
||||
insertInstance: async () => undefined,
|
||||
updateInstance: async () => undefined,
|
||||
findInstanceByTask: async () => undefined,
|
||||
nodeOwners: async () => ({}),
|
||||
setNodeOwner: async () => undefined,
|
||||
},
|
||||
activities: { faces: async () => new Map() },
|
||||
org: {
|
||||
|
||||
@@ -69,6 +69,8 @@ function harness(validationErrors: readonly string[]): Harness {
|
||||
},
|
||||
updateInstance: async () => undefined,
|
||||
findInstanceByTask: async () => undefined,
|
||||
nodeOwners: async () => ({}),
|
||||
setNodeOwner: async () => undefined,
|
||||
},
|
||||
activities: { faces: async () => new Map() },
|
||||
org: { positions: async () => new Set<string>(), candidates: async () => [], workGroupBinding: async () => undefined, isActive: async () => true },
|
||||
|
||||
@@ -79,6 +79,10 @@ export type ProcessService = {
|
||||
listWorkflows(limit?: number): Promise<WorkflowRecord[]>;
|
||||
instancesOf(workflowId: string): Promise<NodeInstance[]>;
|
||||
|
||||
/* ── 节点负责人(运营配置):显式指派 > 这里 > 轮值 ── */
|
||||
nodeOwners(packageId: string): Promise<Record<string, string>>;
|
||||
setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise<void>;
|
||||
|
||||
/** 任务终态到达:完成即继续流转;非完成即走声明的失败走向 */
|
||||
onTaskTerminal(
|
||||
taskId: string,
|
||||
|
||||
@@ -63,3 +63,14 @@ export const nodeInstanceTable = sys.table(
|
||||
index('node_instance_task_idx').on(t.taskId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* 节点负责人(**运营配置**,2026-07-30):流程包的哪个节点归谁。
|
||||
* 起单时垫在显式指派之下、轮值之上 —— 显式指派 > 这里 > 员工组轮值 > 全员轮值。
|
||||
* 不随包版本:它说的是「这家企业现在这活谁管」,不是流程长什么样。
|
||||
*/
|
||||
export const nodeOwnerTable = sys.table('node_owner', {
|
||||
packageId: text('package_id').notNull(),
|
||||
nodeId: text('node_id').notNull(),
|
||||
staffId: text('staff_id').notNull(),
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { processPackageSchema } from '@contracts';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { ProcessStore, WorkflowRecord } from '../../port.ts';
|
||||
import type { NodeInstance } from '../domain/owner-resolution.ts';
|
||||
import { nodeInstanceTable, processPackageTable, workflowTable } from './process-schema.ts';
|
||||
import { nodeInstanceTable, processPackageTable, workflowTable, nodeOwnerTable } from './process-schema.ts';
|
||||
|
||||
type WorkflowRow = typeof workflowTable.$inferSelect;
|
||||
|
||||
@@ -195,6 +195,16 @@ export function createProcessStore(db: Db): ProcessStore {
|
||||
);
|
||||
},
|
||||
|
||||
async nodeOwners(packageId) {
|
||||
const rows = await db.select().from(nodeOwnerTable).where(eq(nodeOwnerTable.packageId, packageId));
|
||||
return Object.fromEntries(rows.map((r) => [r.nodeId, r.staffId]));
|
||||
},
|
||||
async setNodeOwner(packageId, nodeId, staffId) {
|
||||
// 一节点至多一位运营负责人:先清后写;undefined = 撤掉,回到轮值
|
||||
await db.delete(nodeOwnerTable).where(and(eq(nodeOwnerTable.packageId, packageId), eq(nodeOwnerTable.nodeId, nodeId)));
|
||||
if (staffId !== undefined) await db.insert(nodeOwnerTable).values({ packageId, nodeId, staffId });
|
||||
},
|
||||
|
||||
async findInstanceByTask(taskId) {
|
||||
const rows = await db
|
||||
.select()
|
||||
|
||||
@@ -189,6 +189,12 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
const binding =
|
||||
input.source.by === undefined ? undefined : await deps.org.workGroupBinding(input.source.by);
|
||||
|
||||
/*
|
||||
* 负责人合并(2026-07-30):运营中心 · 工作流里配的「节点负责人」垫在显式指派之下。
|
||||
* 显式指派赢 —— 测试台点名是这一单的意图;运营配置是常态,轮值是兜底。
|
||||
*/
|
||||
const opsOwners = await deps.store.nodeOwners(input.packageId);
|
||||
const assignments: Record<string, string> = { ...opsOwners, ...input.assignments };
|
||||
const record: WorkflowRecord = {
|
||||
id: deps.ids.next(),
|
||||
packageId: pkg.id,
|
||||
@@ -201,8 +207,8 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
vars: {},
|
||||
startedAt: deps.clock.now().toISOString(),
|
||||
...(binding === undefined ? {} : { workGroup: { ...binding } }),
|
||||
// 节点指派:起单时定死某几个节点归谁(测试台定向);不传即照旧轮值
|
||||
...(input.assignments === undefined ? {} : { assignments: { ...input.assignments } }),
|
||||
// 节点指派三层:显式指派(测试台定向)> 运营配置的节点负责人 > 轮值
|
||||
...(Object.keys(assignments).length === 0 ? {} : { assignments }),
|
||||
};
|
||||
await deps.store.insertWorkflow(record);
|
||||
await deps.events.publish({ workflowId: record.id, kind: 'workflow-started', detail: { source: input.source } });
|
||||
@@ -223,6 +229,13 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
return deps.store.listInstances(workflowId);
|
||||
},
|
||||
|
||||
async nodeOwners(packageId) {
|
||||
return deps.store.nodeOwners(packageId);
|
||||
},
|
||||
async setNodeOwner(packageId, nodeId, staffId) {
|
||||
await deps.store.setNodeOwner(packageId, nodeId, staffId);
|
||||
},
|
||||
|
||||
/*
|
||||
* 执行面五方法 —— 全部委托 FlowRuntime(M5-1 切面):
|
||||
* 语义、留痕与副作用编排住在运行时实现里,薄壳只转发。
|
||||
|
||||
@@ -93,6 +93,10 @@ export type ProcessStore = {
|
||||
patch: Partial<NodeInstance> & { taskId?: string },
|
||||
): Promise<void>;
|
||||
findInstanceByTask(taskId: string): Promise<{ workflowId: string; nodeId: string; instance: number } | undefined>;
|
||||
|
||||
/* ── 节点负责人(运营配置) ──────────────────────────────── */
|
||||
nodeOwners(packageId: string): Promise<Record<string, string>>;
|
||||
setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise<void>;
|
||||
};
|
||||
|
||||
/** 活动契约面 —— app 注入 `activity-registry`(L4):编排校验与起单都要它 */
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
被依赖: event(经 `TriggerMatcher` 端口,由 app 注入 —— event 是 L1,不反向依赖本模块)、app
|
||||
|
||||
不变量(OS 3.1 / 6.3):
|
||||
- **命中不等于必起**(2026-07-30 触发确认链路):触发器可勾「需人工确认」(运营配置,
|
||||
设计面的 upsert 不写 confirm 两列,只有 setConfirm 动)。勾了,命中后装配好的入参
|
||||
存成待确认单(`sys.trigger_pending`),带来件内容的系统消息(`ConfirmDesk` 端口)送
|
||||
指定人工作台;`confirmPending` 才起单。**先认领后起单**(consumed_at 空才认得到),
|
||||
并发确认只有一个赢;起单失败退回认领,不留死票。登记触发不走确认 ——
|
||||
登记那一下本身就是人在确认;勾了确认没指定人 = 配置错误入死信,不静默直起。
|
||||
- **触发器与流程包平级**:配置独立存放,增删启停**不改流程包的任何版本**
|
||||
- **只负责起单**:经调用接口调起流程包;流程包与活动内部不含任何触发逻辑
|
||||
- **同一事件流,不另设通路**:`match` 供 event 的三级消费判定使用
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 触发确认链路(2026-07-30,用户口述五步的第 4/5 步)——
|
||||
* 勾了「需人工确认」,**命中不等于起单**:
|
||||
* 命中 → 装配好的入参存成待确认单 + 系统消息到指定人工作台 → 他点头 → 才起单。
|
||||
*
|
||||
* 这里守四条命门:
|
||||
* ① 勾了确认:不起单,发确认消息,入参存的是**装配后**的(确认时不重新装配);
|
||||
* ② 点头:起单、来源记确认人、幂等 —— 第二次点头回同一条流,绝不起两条;
|
||||
* ③ 并发点头:store 认领只让一个赢,输家拿到的是赢家那条流;
|
||||
* ④ 登记触发不走确认 —— 登记那一下本身就是人在确认;没指定确认人是配置错误,入死信。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TriggerConfig } from '@contracts';
|
||||
import type { PendingFire } from '../contract.ts';
|
||||
import { createTriggerService } from '../internal/usecase/trigger-service.ts';
|
||||
import type { TriggerServiceDeps } from '../internal/usecase/trigger-service.ts';
|
||||
|
||||
const CONFIG: TriggerConfig = {
|
||||
id: 'trg-1',
|
||||
name: '客户RFQ询价',
|
||||
enabled: true,
|
||||
processPackageId: 'unified-quote',
|
||||
mapping: [
|
||||
{ source: { from: 'expression', sfeel: 'keys.customerCode' }, target: 'customerCode' },
|
||||
{ source: { from: 'expression', sfeel: 'subject' }, target: 'subject' },
|
||||
],
|
||||
confirm: { required: true, staffId: 'staff-bd' },
|
||||
spec: { kind: 'event', eventType: 'data', rule: 'true' },
|
||||
} as TriggerConfig;
|
||||
|
||||
type Harness = {
|
||||
service: ReturnType<typeof createTriggerService>;
|
||||
pendings: Map<string, PendingFire>;
|
||||
started: Array<{ subject: string; by?: string; data: Record<string, unknown> }>;
|
||||
desk: Array<{ pendingId: string; staffId: string }>;
|
||||
dead: string[];
|
||||
};
|
||||
|
||||
function harness(config: TriggerConfig, options?: { failFirstStart?: boolean }): Harness {
|
||||
const pendings = new Map<string, PendingFire>();
|
||||
const started: Harness['started'] = [];
|
||||
const desk: Harness['desk'] = [];
|
||||
const dead: string[] = [];
|
||||
let seq = 0;
|
||||
let failed = false;
|
||||
|
||||
const deps: TriggerServiceDeps = {
|
||||
store: {
|
||||
list: async () => [config],
|
||||
get: async () => config,
|
||||
upsert: async (c) => c,
|
||||
setEnabled: async () => undefined,
|
||||
setConfirm: async () => undefined,
|
||||
remove: async () => undefined,
|
||||
claim: async () => true,
|
||||
lastFiredAt: async () => undefined,
|
||||
markFired: async () => undefined,
|
||||
createPending: async (p) => {
|
||||
pendings.set(p.id, p);
|
||||
},
|
||||
getPending: async (id) => pendings.get(id),
|
||||
pendingByIds: async (ids) => ids.flatMap((id) => (pendings.has(id) ? [pendings.get(id)!] : [])),
|
||||
claimPending: async (id, at) => {
|
||||
const p = pendings.get(id);
|
||||
if (p === undefined || p.consumedAt !== undefined) return false;
|
||||
pendings.set(id, { ...p, consumedAt: at.toISOString() });
|
||||
return true;
|
||||
},
|
||||
setPendingWorkflow: async (id, workflowId) => {
|
||||
pendings.set(id, { ...pendings.get(id)!, workflowId });
|
||||
},
|
||||
releasePending: async (id) => {
|
||||
const { consumedAt: _drop, ...rest } = pendings.get(id)!;
|
||||
pendings.set(id, rest as PendingFire);
|
||||
},
|
||||
},
|
||||
process: {
|
||||
start: async (input) => {
|
||||
if (options?.failFirstStart === true && started.length === 0 && failed === false) {
|
||||
failed = true;
|
||||
throw new Error('起单炸了(注入)');
|
||||
}
|
||||
started.push({ subject: input.subject, ...(input.source.by === undefined ? {} : { by: input.source.by }), data: input.data });
|
||||
return { id: `wf-${started.length}` };
|
||||
},
|
||||
requiredInputs: async () => ['customerCode'],
|
||||
},
|
||||
sfeel: {
|
||||
condition: () => true,
|
||||
expression: (expr, scope) => expr.split('.').reduce<unknown>((acc, seg) => (acc as Record<string, unknown>)?.[seg], scope),
|
||||
},
|
||||
confirmDesk: {
|
||||
request: async (input) => {
|
||||
desk.push({ pendingId: input.pendingId, staffId: input.staffId });
|
||||
},
|
||||
},
|
||||
deadLetters: {
|
||||
report: async (input) => {
|
||||
dead.push(input.reason);
|
||||
},
|
||||
},
|
||||
ledger: { scan: async () => [] },
|
||||
clock: { now: () => new Date('2026-07-30T00:00:00.000Z') },
|
||||
ids: { next: () => `id-${(seq += 1)}` },
|
||||
};
|
||||
|
||||
return { service: createTriggerService(deps), pendings, started, desk, dead };
|
||||
}
|
||||
|
||||
const SCOPE = {
|
||||
type: 'data',
|
||||
keys: { customerCode: 'C014' },
|
||||
payload: { subject: '【询价】C014', body: '正文', from: 'buyer@x.com' },
|
||||
source: 'channel',
|
||||
occurredAt: '2026-07-30T00:00:00.000Z',
|
||||
} as const;
|
||||
|
||||
describe('触发确认链路', () => {
|
||||
it('① 勾了确认:命中不起单 —— 存待确认单 + 消息到指定人,入参是装配后的', async () => {
|
||||
const h = harness(CONFIG);
|
||||
const out = await h.service.fireByEvent('trg-1', 'evt-1', SCOPE);
|
||||
|
||||
expect(out.workflowId, '不该直接起单').toBeUndefined();
|
||||
expect(out.pendingId).toBeDefined();
|
||||
expect(h.started).toHaveLength(0);
|
||||
expect(h.desk).toEqual([{ pendingId: out.pendingId, staffId: 'staff-bd' }]);
|
||||
|
||||
const pending = h.pendings.get(out.pendingId!)!;
|
||||
expect(pending.data['customerCode'], '存的是装配后的入参,确认时不重新装配').toBe('C014');
|
||||
expect(pending.preview['from'], '消息要带来件内容').toBe('buyer@x.com');
|
||||
expect(pending.staffId, '确认人快照在票上').toBe('staff-bd');
|
||||
});
|
||||
|
||||
it('② 点头起单:来源记确认人;第二次点头回同一条流,绝不起两条', async () => {
|
||||
const h = harness(CONFIG);
|
||||
const { pendingId } = await h.service.fireByEvent('trg-1', 'evt-1', SCOPE);
|
||||
|
||||
const first = await h.service.confirmPending(pendingId!, 'staff-bd');
|
||||
expect(first.alreadyStarted).toBe(false);
|
||||
expect(h.started).toHaveLength(1);
|
||||
expect(h.started[0]!.by, '这条流是谁点头放行的,留痕要看得到').toBe('staff-bd');
|
||||
|
||||
const second = await h.service.confirmPending(pendingId!, 'staff-bd');
|
||||
expect(second.alreadyStarted).toBe(true);
|
||||
expect(second.workflowId).toBe(first.workflowId);
|
||||
expect(h.started, '幂等 —— 只起一条').toHaveLength(1);
|
||||
});
|
||||
|
||||
it('③ 起单失败把认领退回去 —— 这一票还能再确认,不变成死票', async () => {
|
||||
const h = harness(CONFIG, { failFirstStart: true });
|
||||
const { pendingId } = await h.service.fireByEvent('trg-1', 'evt-1', SCOPE);
|
||||
|
||||
await expect(h.service.confirmPending(pendingId!, 'staff-bd')).rejects.toThrow('起单炸了');
|
||||
expect(h.pendings.get(pendingId!)!.consumedAt, '认领退回去了').toBeUndefined();
|
||||
|
||||
const retry = await h.service.confirmPending(pendingId!, 'staff-bd');
|
||||
expect(retry.alreadyStarted).toBe(false);
|
||||
expect(h.started).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('④ 登记触发不走确认;勾了确认没指定人 = 配置错误入死信', async () => {
|
||||
// 登记规格 + 零映射(登记表单本身就按调用接口成形)—— 与种子里的「人工登记起单」同形
|
||||
const h = harness({ ...CONFIG, mapping: [], spec: { kind: 'registration' } } as TriggerConfig);
|
||||
// 登记:登记那一下本身就是人在确认 —— 直接起单
|
||||
const reg = await h.service.fireByRegistration('trg-1', 'staff-op', { customerCode: 'C014', subject: 's' });
|
||||
expect(reg.workflowId).toBeDefined();
|
||||
expect(h.desk, '登记不发确认消息').toHaveLength(0);
|
||||
|
||||
const h2 = harness({ ...CONFIG, confirm: { required: true } } as TriggerConfig);
|
||||
const out = await h2.service.fireByEvent('trg-1', 'evt-2', SCOPE);
|
||||
expect(out.skipped).toBe('no-confirmer');
|
||||
expect(h2.started).toHaveLength(0);
|
||||
expect(h2.dead.some((r) => r.includes('确认人')), '如实入死信,不静默丢、不擅自直起').toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -22,10 +22,28 @@ export class TriggerInputMissingError extends Error {
|
||||
export type FireOutcome = {
|
||||
readonly triggerId: string;
|
||||
readonly workflowId?: string;
|
||||
readonly skipped?: 'duplicate' | 'disabled' | 'input-missing';
|
||||
/** 命中但勾了「需人工确认」:没起单,等指定人点头 —— 这一票的号 */
|
||||
readonly pendingId?: string;
|
||||
readonly skipped?: 'duplicate' | 'disabled' | 'input-missing' | 'no-confirmer';
|
||||
readonly missing?: readonly string[];
|
||||
};
|
||||
|
||||
/** 待确认单:装配好的一次起单,等指定人点「确认起单」 */
|
||||
export type PendingFire = {
|
||||
readonly id: string;
|
||||
readonly triggerId: string;
|
||||
readonly packageId: string;
|
||||
/** 确认人快照 —— 确认路由按它认门 */
|
||||
readonly staffId: string;
|
||||
readonly subject: string;
|
||||
readonly data: Record<string, unknown>;
|
||||
readonly source: { readonly kind: 'periodic' | 'event' | 'registration'; readonly ref: string; readonly by?: string };
|
||||
readonly preview: Readonly<Record<string, string>>;
|
||||
readonly createdAt: string;
|
||||
readonly consumedAt?: string;
|
||||
readonly workflowId?: string;
|
||||
};
|
||||
|
||||
export type TriggerService = {
|
||||
list(): Promise<TriggerConfig[]>;
|
||||
upsert(config: TriggerConfig): Promise<TriggerConfig>;
|
||||
@@ -40,4 +58,12 @@ export type TriggerService = {
|
||||
fireByRegistration(triggerId: string, by: string, data: Record<string, unknown>): Promise<FireOutcome>;
|
||||
/** 周期触发的到点扫描(由 pg-boss 定时调起) */
|
||||
sweepPeriodic(now: Date): Promise<FireOutcome[]>;
|
||||
|
||||
/* ── 触发确认链路(运营配置) ────────────────────────────────── */
|
||||
/** 运营口:要不要人工确认、谁来确认 —— 与设计面的 upsert 分口 */
|
||||
setConfirm(id: string, confirm: { required: boolean; staffId?: string }): Promise<void>;
|
||||
/** 指定人点头 —— 幂等:已被确认过就回那条流的号,绝不起第二条 */
|
||||
confirmPending(pendingId: string, by: string): Promise<{ workflowId: string; alreadyStarted: boolean }>;
|
||||
/** 待确认单批读 —— 工作台消息列表就地贴确认状态用 */
|
||||
pendingByIds(ids: readonly string[]): Promise<PendingFire[]>;
|
||||
};
|
||||
|
||||
@@ -14,12 +14,37 @@ export const triggerTable = sys.table(
|
||||
processPackageId: text('process_package_id').notNull(),
|
||||
mapping: jsonb('mapping').notNull().default([]),
|
||||
spec: jsonb('spec').notNull(),
|
||||
/*
|
||||
* 人工确认(**运营配置**):设计面的 upsert 不写这两列 —— 那会把运营的勾抹掉;
|
||||
* 只有 setConfirm 动它们。读回时并进 TriggerConfig.confirm。
|
||||
*/
|
||||
confirmRequired: boolean('confirm_required').notNull().default(false),
|
||||
confirmStaffId: text('confirm_staff_id'),
|
||||
lastFiredAt: timestamp('last_fired_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('trigger_enabled_idx').on(t.enabled)],
|
||||
);
|
||||
|
||||
/**
|
||||
* 待确认单:命中但等人点头的那一票 —— 装配好的调用入参存在这里,
|
||||
* 确认那一刻直接起单,不重新装配(事件早已过去,重抽可能抽不到了)。
|
||||
*/
|
||||
export const triggerPendingTable = sys.table('trigger_pending', {
|
||||
id: text('id').primaryKey(),
|
||||
triggerId: text('trigger_id').notNull(),
|
||||
packageId: text('package_id').notNull(),
|
||||
/** 确认人快照:发消息那一刻的指定人 —— 确认路由按它认门 */
|
||||
staffId: text('staff_id').notNull(),
|
||||
subject: text('subject').notNull(),
|
||||
data: jsonb('data').notNull(),
|
||||
source: jsonb('source').notNull(),
|
||||
preview: jsonb('preview').notNull().default({}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
consumedAt: timestamp('consumed_at', { withTimezone: true }),
|
||||
workflowId: text('workflow_id'),
|
||||
});
|
||||
|
||||
/** 起单幂等表:同一事件对同一触发器至多起一单 */
|
||||
export const triggerClaimTable = sys.table('trigger_claim', {
|
||||
key: text('key').primaryKey(),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* trigger 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
|
||||
*/
|
||||
import { eq } from 'drizzle-orm';
|
||||
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, triggerTable } from './trigger-schema.ts';
|
||||
import { triggerClaimTable, triggerPendingTable, triggerTable } from './trigger-schema.ts';
|
||||
|
||||
type Row = typeof triggerTable.$inferSelect;
|
||||
|
||||
@@ -18,9 +19,28 @@ function toConfig(row: Row): TriggerConfig {
|
||||
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() {
|
||||
@@ -32,6 +52,7 @@ export function createTriggerStore(db: Db): TriggerStore {
|
||||
return rows[0] === undefined ? undefined : toConfig(rows[0]);
|
||||
},
|
||||
async upsert(config) {
|
||||
// 设计面的 upsert **不含 confirm 两列** —— 运营的勾只归 setConfirm 动
|
||||
const values = {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
@@ -69,5 +90,49 @@ export function createTriggerStore(db: Db): TriggerStore {
|
||||
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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* module: trigger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { TriggerConfig } from '@contracts';
|
||||
import type { PendingFire } from './contract.ts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
export type IdGenerator = { next(): string };
|
||||
@@ -11,11 +12,37 @@ export type TriggerStore = {
|
||||
get(id: string): Promise<TriggerConfig | undefined>;
|
||||
upsert(config: TriggerConfig): Promise<TriggerConfig>;
|
||||
setEnabled(id: string, enabled: boolean): Promise<void>;
|
||||
/** 运营口:确认配置 —— 设计面的 upsert 不碰这两列 */
|
||||
setConfirm(id: string, confirm: { required: boolean; staffId?: string }): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
/** 幂等:同一键至多起一单 */
|
||||
claim(key: string): Promise<boolean>;
|
||||
lastFiredAt(id: string): Promise<Date | undefined>;
|
||||
markFired(id: string, at: Date): Promise<void>;
|
||||
|
||||
/* ── 待确认单(触发确认链路) ────────────────────────────────── */
|
||||
createPending(pending: PendingFire): Promise<void>;
|
||||
getPending(id: string): Promise<PendingFire | undefined>;
|
||||
pendingByIds(ids: readonly string[]): Promise<PendingFire[]>;
|
||||
/** 认领:consumed_at 空才认得到 —— 两个人同时点确认,只有一个赢,绝不起两条流 */
|
||||
claimPending(id: string, at: Date): Promise<boolean>;
|
||||
/** 起单成功后把流号写回;起单失败时用 releasePending 把认领退回去 */
|
||||
setPendingWorkflow(id: string, workflowId: string): Promise<void>;
|
||||
releasePending(id: string): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 确认台 —— app 注入 `message`(L2):命中但要人点头时,
|
||||
* 把带来件内容的系统消息送到指定人的工作台。
|
||||
*/
|
||||
export type ConfirmDesk = {
|
||||
request(input: {
|
||||
pendingId: string;
|
||||
staffId: string;
|
||||
triggerName: string;
|
||||
subject: string;
|
||||
preview: Readonly<Record<string, string>>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
/** 起单 —— app 注入 `process`(L7 同层?否:trigger 在 process 之上同层,经 app 装配) */
|
||||
|
||||
@@ -25,6 +25,7 @@ import { domainShort, sortDomains } from '@web-shared/gov/domains.ts';
|
||||
import { queryKeys } from './query.ts';
|
||||
import { BlueprintHome } from '@features/gov-blueprint';
|
||||
import { TasksHome, TaskLogPage, TaskPage, WfPage } from '@features/gov-task';
|
||||
import { RunsHome } from '@features/gov-runs';
|
||||
import { EmpPage, PeopleHome, RolePage } from '@features/gov-staff';
|
||||
import { PkgPage, WorkflowsHome } from '@features/gov-process';
|
||||
import { TriggerPage, TriggerRulesHome } from '@features/gov-trigger';
|
||||
@@ -293,6 +294,7 @@ function ZoneHome({ zone, tab, nav }: { zone: GovZone; tab: string; nav: Nav })
|
||||
testbed: TestbedHome,
|
||||
// 运营中心
|
||||
cockpit: CockpitHome,
|
||||
runs: RunsHome,
|
||||
tasks: TasksHome,
|
||||
people: PeopleHome,
|
||||
ledgers: LedgersHome,
|
||||
|
||||
@@ -665,6 +665,21 @@
|
||||
.g-maprow > .g-btn { grid-column: 6; justify-self: stretch; padding: 4px 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.g-maparr { color: var(--sh-steel, #46586a); font-weight: 700; text-align: center; }
|
||||
|
||||
/* ── 运营中心 · 工作流 · 运行配置:一包一卡,里面两节(触发器 / 节点负责人)── */
|
||||
.g-runconf { display: flex; flex-direction: column; gap: 4px; padding: 10px 14px 12px; }
|
||||
.g-runconf + .g-runconf { border-top: 1px dashed var(--line-soft); }
|
||||
.g-runconf-h { display: flex; align-items: center; gap: 7px; padding-bottom: 6px; font: 600 9.5px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); }
|
||||
.g-runconf-none { margin: 0; font-size: 11.5px; line-height: 1.8; color: var(--ink-3); }
|
||||
.g-runconf-r { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; padding: 7px 0; border-bottom: 1px dashed var(--line-soft); }
|
||||
.g-runconf-r:last-child { border-bottom: 0; }
|
||||
.g-runconf-k { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ink-2); cursor: pointer; user-select: none; }
|
||||
.g-runconf-k input { accent-color: var(--brand); width: 14px; height: 14px; cursor: pointer; }
|
||||
.g-runconf-r .g-select { width: 180px; }
|
||||
.g-runconf-own { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 4px 24px; }
|
||||
.g-runconf-node { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto minmax(140px, 170px); align-items: center; gap: 9px; padding: 5px 0; }
|
||||
.g-runconf-node .nid { font-family: var(--mono); font-size: 11px; font-weight: 700; color: var(--ink); }
|
||||
.g-runconf-node .lb { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--ink-2); }
|
||||
|
||||
/* ═══════════ 测试台 · 仿真试跑(2026-07-30 重制)═══════════
|
||||
概念:**浅色桌面上的一台仪器**。整台机器是页面里唯一的暗色区 —— 有边、有圆角、
|
||||
有台面,不是把纸刷黑。机器说 mono(读数、灯、编号、TRACE),
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { FormField, ListMessagesOutput, SystemMessageView } from '@contracts';
|
||||
import { answerMessageRoute, listMessagesRoute, markMessageReadRoute } from '@contracts';
|
||||
import { answerMessageRoute, confirmTriggerPendingRoute, listMessagesRoute, markMessageReadRoute } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import ScrollArea from '@web-shared/ScrollArea.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
@@ -63,7 +63,7 @@ export function MessageDesk() {
|
||||
<ScrollArea className="jc-sa--fill" vpClassName="task-list">
|
||||
{visible.map((m) => {
|
||||
const meta = metaOf(m.kind);
|
||||
const pending = m.card !== undefined && !m.card.answered;
|
||||
const pending = (m.card !== undefined && !m.card.answered) || (m.confirm !== undefined && !m.confirm.consumed);
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
@@ -77,7 +77,7 @@ export function MessageDesk() {
|
||||
<span className="jc-mcard-top">
|
||||
<em>{meta.kicker}</em>
|
||||
{pending ? (
|
||||
<Chip tone="todo" label="待作答" pulse />
|
||||
<Chip tone="todo" label={m.confirm !== undefined ? '待确认' : '待作答'} pulse />
|
||||
) : (
|
||||
<Chip tone={m.read ? 'done' : 'brand'} label={m.read ? '已读' : '未读'} pulse={!m.read} />
|
||||
)}
|
||||
@@ -122,6 +122,12 @@ export function MessageDesk() {
|
||||
<div className="jc-summary">{sel.body}</div>
|
||||
</Section>
|
||||
|
||||
{sel.confirm !== undefined && (
|
||||
<Section icon="zap" title="确认起单" extra="触发器命中,按运营配置须你点头才起单">
|
||||
<ConfirmFire confirm={sel.confirm} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{sel.card !== undefined && (
|
||||
<Section icon="messages-square" title="就地作答" extra={`来源任务 ${sel.card.sourceTaskId.slice(0, 8)}`}>
|
||||
{sel.card.rejectedReason !== undefined && (
|
||||
@@ -228,3 +234,37 @@ function ElicitationForm({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认起单(触发确认链路第 5 步)—— 点一下,那条流才真的起。
|
||||
* 幂等:已被确认过服务端回同一条流的号,这里如实显示,绝不催人再点。
|
||||
*/
|
||||
function ConfirmFire({ confirm }: { confirm: NonNullable<SystemMessageView['confirm']> }) {
|
||||
const queryClient = useQueryClient();
|
||||
const fire = useMutation({
|
||||
mutationFn: () => call<{ workflowId: string; alreadyStarted: boolean }>(confirmTriggerPendingRoute, { params: { id: confirm.pendingId } }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['desk'] });
|
||||
},
|
||||
});
|
||||
const done = confirm.consumed || fire.isSuccess;
|
||||
const wfId = fire.data?.workflowId ?? confirm.workflowId;
|
||||
if (done) {
|
||||
return (
|
||||
<div className="jc-mod-donehint">
|
||||
<Ico name="circle-check" s={15} />
|
||||
已确认起单{wfId === undefined ? '' : ` · 工作流 ${wfId.slice(0, 8)}`} —— 任务会按负责人配置落到各工作台
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<NoteBand>这封来件命中了触发器,但运营配置要求人工确认 —— 你点了,流程才起;不点,它就停在这儿。</NoteBand>
|
||||
<button className="jc-btn primary" disabled={fire.isPending} onClick={() => fire.mutate()}>
|
||||
<Ico name="play" s={13} />
|
||||
{fire.isPending ? '起单中…' : '确认起单'}
|
||||
</button>
|
||||
{fire.isError && <NoteBand>没起成:{(fire.error as { message?: string }).message ?? '未知原因'}</NoteBand>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* feature `gov-runs` —— 运营中心 · 工作流:**流程包的运行时**。
|
||||
* 在途实例的监控,和每个包的运行配置(起单开关 / 人工确认 / 节点负责人)。
|
||||
*/
|
||||
export { RunsHome } from './internal/panel.tsx';
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 运营中心 · 工作流 —— **流程包的运行时**(设计中心只负责设计,运营中心管细节)。
|
||||
*
|
||||
* 两页签:
|
||||
* · 在途:工作流看板(从任务区迁入 —— 工作流一级归这儿,任务区只留活动一级);
|
||||
* · 运行配置:每个包**今天怎么跑**的细节,设计一个字不碰 ——
|
||||
* 触发器:起单开关 / 命中后要不要人工确认 / 谁来确认(触发确认链路第 4 步的配置面)
|
||||
* 节点负责人:流程包的哪个节点归谁 —— 显式指派 > 这里 > 轮值
|
||||
*/
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import type { TriggerConfig } from '@contracts';
|
||||
import {
|
||||
listNodeOwnersRoute,
|
||||
setNodeOwnerRoute,
|
||||
setTriggerConfirmRoute,
|
||||
toggleTriggerRoute,
|
||||
} from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { Card, Chip, Clear, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { useGovPackages, useGovStaff, useGovTriggers } from '@web-shared/gov/gov-api.ts';
|
||||
import { WorkflowBoard } from '@features/gov-task';
|
||||
|
||||
export function RunsHome({ tab, nav }: { tab: string; nav: Nav }) {
|
||||
if (tab === 'conf') return <RunConfig />;
|
||||
return <WorkflowBoard nav={nav} />;
|
||||
}
|
||||
|
||||
/** 治理写动作统一入口:成功即失效相关查询,画面由服务端重投影 */
|
||||
function useGovAction<TInput>(mutate: (input: TInput) => Promise<unknown>, keys: readonly string[]) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: mutate,
|
||||
onSuccess: () => {
|
||||
for (const key of keys) void queryClient.invalidateQueries({ queryKey: ['gov', key] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== 运行配置:一包一节 ==================== */
|
||||
|
||||
function RunConfig() {
|
||||
const pkgQ = useGovPackages();
|
||||
const trgQ = useGovTriggers();
|
||||
const staffQ = useGovStaff();
|
||||
const packages = (pkgQ.data?.packages ?? []).filter((p) => p.state === 'active');
|
||||
const triggers = trgQ.data?.triggers ?? [];
|
||||
const staff = (staffQ.data?.staff ?? []).filter((s) => s.state === 'active');
|
||||
|
||||
return (
|
||||
<QueryGate query={pkgQ}>
|
||||
{packages.length === 0 ? (
|
||||
<Clear>没有生效的流程包 —— 设计中心激活一版,这里就有它的运行配置。</Clear>
|
||||
) : (
|
||||
<>
|
||||
{packages.map((p) => (
|
||||
<PackageConfig
|
||||
key={p.id}
|
||||
packageId={p.id}
|
||||
name={p.name}
|
||||
nodes={p.nodes
|
||||
.filter((n) => n.kind === 'activity' || n.kind === 'approval')
|
||||
.map((n) => ({ id: n.id, label: n.label, kind: n.kind }))}
|
||||
triggers={triggers.filter((t) => t.processPackageId === p.id)}
|
||||
staff={staff.map((s) => ({ id: s.id, name: s.name, code: s.position.code }))}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
|
||||
type StaffOpt = { id: string; name: string; code: string };
|
||||
|
||||
function PackageConfig({
|
||||
packageId,
|
||||
name,
|
||||
nodes,
|
||||
triggers,
|
||||
staff,
|
||||
}: {
|
||||
packageId: string;
|
||||
name: string;
|
||||
nodes: ReadonlyArray<{ id: string; label: string; kind: string }>;
|
||||
triggers: readonly TriggerConfig[];
|
||||
staff: readonly StaffOpt[];
|
||||
}) {
|
||||
const ownersQ = useQuery({
|
||||
queryKey: ['gov', 'node-owners', packageId],
|
||||
queryFn: () => call<{ owners: Array<{ nodeId: string; staffId: string }> }>(listNodeOwnersRoute, { params: { id: packageId } }),
|
||||
});
|
||||
const owners = new Map((ownersQ.data?.owners ?? []).map((o) => [o.nodeId, o.staffId]));
|
||||
|
||||
const toggle = useGovAction(
|
||||
(input: { id: string; enabled: boolean }) =>
|
||||
call(toggleTriggerRoute, { params: { id: input.id }, body: { enabled: input.enabled } }),
|
||||
['triggers'],
|
||||
);
|
||||
const confirm = useGovAction(
|
||||
(input: { id: string; required: boolean; staffId?: string }) =>
|
||||
call(setTriggerConfirmRoute, {
|
||||
params: { id: input.id },
|
||||
body: { required: input.required, ...(input.staffId === undefined ? {} : { staffId: input.staffId }) },
|
||||
}),
|
||||
['triggers'],
|
||||
);
|
||||
const setOwner = useGovAction(
|
||||
(input: { nodeId: string; staffId: string | null }) =>
|
||||
call(setNodeOwnerRoute, { params: { id: packageId, nodeId: input.nodeId }, body: { staffId: input.staffId } }),
|
||||
['node-owners'],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card ico="workflow" title={name} extra={<span className="g-mono">{packageId}</span>}>
|
||||
{/* ── 触发器的运营面:开不开 · 要不要人点头 · 谁点头 —— 规则本身在设计中心改 ── */}
|
||||
<div className="g-runconf">
|
||||
<div className="g-runconf-h">
|
||||
<Ico name="zap" s={11} />
|
||||
触发器 —— 起单开关与人工确认
|
||||
</div>
|
||||
{triggers.length === 0 ? (
|
||||
<p className="g-runconf-none">这个包还没有调起规则 —— 去设计中心 · 触发器配一条。</p>
|
||||
) : (
|
||||
triggers.map((t) => {
|
||||
const confirmed = t.confirm.required;
|
||||
return (
|
||||
<div key={t.id} className="g-runconf-r">
|
||||
<St tone={t.enabled ? 'ok' : 'mute'} ico="zap">
|
||||
{t.name}
|
||||
</St>
|
||||
<label className="g-runconf-k">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={t.enabled}
|
||||
onChange={(e) => toggle.mutate({ id: t.id, enabled: e.target.checked })}
|
||||
/>
|
||||
起单
|
||||
</label>
|
||||
<label className="g-runconf-k" title="勾了:命中后先发系统消息到指定人工作台,他点「确认起单」流程才起">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) =>
|
||||
confirm.mutate({
|
||||
id: t.id,
|
||||
required: e.target.checked,
|
||||
// 勾上时没选过人就先落到第一位 —— 服务端要求勾必有人,界面不递交半份配置
|
||||
...(e.target.checked && (t.confirm.staffId ?? staff[0]?.id) !== undefined
|
||||
? { staffId: t.confirm.staffId ?? staff[0]!.id }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
命中后须人工确认
|
||||
</label>
|
||||
<select
|
||||
className="g-select"
|
||||
aria-label={`${t.name} 的确认人`}
|
||||
disabled={!confirmed}
|
||||
value={t.confirm.staffId ?? ''}
|
||||
onChange={(e) => confirm.mutate({ id: t.id, required: true, staffId: e.target.value })}
|
||||
>
|
||||
<option value="" disabled>
|
||||
确认人…
|
||||
</option>
|
||||
{staff.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} · {s.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{confirmed && t.confirm.staffId === undefined && <Chip tone="warn">还没指定确认人</Chip>}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 节点负责人:显式指派 > 这里 > 轮值 ── */}
|
||||
<div className="g-runconf">
|
||||
<div className="g-runconf-h">
|
||||
<Ico name="user-check" s={11} />
|
||||
节点负责人 —— 留空即轮值(有员工组按组轮,没有按全员最闲者先)
|
||||
</div>
|
||||
<div className="g-runconf-own">
|
||||
{nodes.map((n) => (
|
||||
<label key={n.id} className="g-runconf-node">
|
||||
<span className="nid">{n.id}</span>
|
||||
<span className="lb">{n.label}</span>
|
||||
{n.kind === 'approval' && <Chip tone="warn">审批</Chip>}
|
||||
<select
|
||||
className="g-select"
|
||||
value={owners.get(n.id) ?? ''}
|
||||
onChange={(e) => setOwner.mutate({ nodeId: n.id, staffId: e.target.value === '' ? null : e.target.value })}
|
||||
>
|
||||
<option value="">轮值</option>
|
||||
{staff.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} · {s.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,4 +2,4 @@
|
||||
* feature `gov-task` 唯一出口(编码规范 §9.1)。
|
||||
* 任务管理:在途监控、控制事件(暂停/叫停/终止)、恢复、改派、任务日志查看
|
||||
*/
|
||||
export { TasksHome, WfPage, TaskPage, TaskLogPage } from './internal/panel.tsx';
|
||||
export { TasksHome, WfPage, TaskPage, TaskLogPage, WorkflowBoard } from './internal/panel.tsx';
|
||||
|
||||
@@ -23,7 +23,6 @@ import { Ava, Card, Chip, Clear, Entry, Facts, Kpi, NList, NRow, Note, PageHead,
|
||||
import type { ActSpec } from '@web-shared/gov/primitives.tsx';
|
||||
import { APR_ST, PARA, RUNTIME_ST, TERMINAL_ST, WF_ST } from '@web-shared/gov/tone.ts';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { TriggerOpsTab } from '@features/gov-trigger';
|
||||
import { FlowFigure } from '@web-shared/gov/flow/FlowFigure.tsx';
|
||||
import {
|
||||
useGovApprovals,
|
||||
@@ -36,14 +35,15 @@ import {
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
export function TasksHome({ tab, nav }: HomeProps) {
|
||||
// 起单开关 = 触发器的**处置面**:今天开不开是当班主管的事,规则怎么写在设计中心改
|
||||
if (tab === 'trg') return <TriggerOpsTab nav={nav} />;
|
||||
return <RuntimeHome tab={tab} nav={nav} />;
|
||||
}
|
||||
|
||||
function RuntimeHome({ tab, nav }: HomeProps) {
|
||||
/**
|
||||
* 在途工作流看板 —— **工作流是流程包的运行时**,一级看板归「运营中心 · 工作流」区;
|
||||
* 任务区(活动的运行时)不再摆它。
|
||||
*/
|
||||
export function WorkflowBoard({ nav }: { nav: Nav }) {
|
||||
const q = useGovTasks();
|
||||
const approvals = useGovApprovals();
|
||||
const tasks = q.data?.tasks ?? [];
|
||||
const workflows = q.data?.workflows ?? [];
|
||||
|
||||
@@ -55,7 +55,6 @@ function RuntimeHome({ tab, nav }: HomeProps) {
|
||||
return stuck ? { name: stuck.ownerName, label: '最后经手 · 待处置' } : undefined;
|
||||
};
|
||||
|
||||
if (tab === 'board') {
|
||||
const lanes = [
|
||||
{ k: 'running', tone: 'brand' },
|
||||
{ k: 'waiting', tone: 'warn' },
|
||||
@@ -122,8 +121,14 @@ function RuntimeHome({ tab, nav }: HomeProps) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeHome({ tab, nav }: HomeProps) {
|
||||
const q = useGovTasks();
|
||||
const approvals = useGovApprovals();
|
||||
const tasks = q.data?.tasks ?? [];
|
||||
const workflows = q.data?.workflows ?? [];
|
||||
|
||||
if (tab === 'apr') {
|
||||
const rows = approvals.data?.approvals ?? [];
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
* feature `gov-trigger` 唯一出口(编码规范 §9.1)。
|
||||
* 触发器的两副面孔:规则定义在设计中心(流程包 - 调起规则),启停在运营中心(任务 - 起单开关)。
|
||||
*/
|
||||
export { TriggerOpsTab, TriggerPage, TriggerRulesHome } from './internal/panel.tsx';
|
||||
export { TriggerPage, TriggerRulesHome } from './internal/panel.tsx';
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { TriggerConfig } from '@contracts';
|
||||
import {
|
||||
TRIGGER_KIND_LABEL,
|
||||
processPackageIdSchema,
|
||||
toggleTriggerRoute,
|
||||
triggerKindOf,
|
||||
upsertTriggerRoute,
|
||||
} from '@contracts';
|
||||
@@ -29,8 +28,6 @@ import {
|
||||
Clear,
|
||||
Field,
|
||||
ListEdit,
|
||||
NList,
|
||||
NRow,
|
||||
Note,
|
||||
PageHead,
|
||||
Prop,
|
||||
@@ -394,62 +391,9 @@ function TriggerForm({
|
||||
|
||||
/* ==================== 运营侧:起单开关(处置面) ==================== */
|
||||
|
||||
export function TriggerOpsTab({ nav }: { nav: Nav }) {
|
||||
const q = useGovTriggers();
|
||||
const triggers = q.data?.triggers ?? [];
|
||||
const toggle = useGovAction(
|
||||
(input: { id: string; enabled: boolean }) =>
|
||||
call(toggleTriggerRoute, { params: { id: input.id }, body: { enabled: input.enabled } }),
|
||||
['triggers'],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryGate query={q}>
|
||||
<>
|
||||
<Note>
|
||||
开关管的是<b>今天起不起单</b>,不是规则本身。停用后此后不再起新单,<b>已起的照常跑完</b>;
|
||||
规则怎么写在设计中心 · 流程包 - 调起规则里改。
|
||||
</Note>
|
||||
{triggers.length === 0 ? (
|
||||
<Clear>还没有调起规则 —— 设计中心配好之后,开关会出现在这里。</Clear>
|
||||
) : (
|
||||
<NList>
|
||||
{triggers.map((t) => (
|
||||
<NRow
|
||||
key={t.id}
|
||||
tone={t.enabled ? 'brand' : 'mute'}
|
||||
ico="zap"
|
||||
title={t.name}
|
||||
sub={ruleText(t)}
|
||||
cols={[
|
||||
{ k: '类别', v: <Chip tone="brand">{TRIGGER_KIND_LABEL[triggerKindOf(t.spec)]}</Chip> },
|
||||
{ k: '调起', v: t.processPackageId },
|
||||
]}
|
||||
right={
|
||||
<St tone={t.enabled ? 'ok' : 'mute'} ico={t.enabled ? 'circle-check' : 'power'}>
|
||||
{t.enabled ? '已启用' : '已停用'}
|
||||
</St>
|
||||
}
|
||||
onClick={() => nav.push({ k: 'trigger', id: t.id })}
|
||||
/>
|
||||
))}
|
||||
</NList>
|
||||
)}
|
||||
{toggle.isError && <Note tone="warn">切换失败,请重试。</Note>}
|
||||
</>
|
||||
</QueryGate>
|
||||
);
|
||||
}
|
||||
|
||||
/* ==================== 触发器详情(两面共用) ==================== */
|
||||
|
||||
export function TriggerPage({ id }: { id: string }) {
|
||||
const q = useGovTriggers();
|
||||
const t = (q.data?.triggers ?? []).find((x) => x.id === id);
|
||||
const toggle = useGovAction(
|
||||
(enabled: boolean) => call(toggleTriggerRoute, { params: { id }, body: { enabled } }),
|
||||
['triggers'],
|
||||
);
|
||||
if (t === undefined) return <Note>触发器不存在。</Note>;
|
||||
return (
|
||||
<>
|
||||
@@ -462,27 +406,13 @@ export function TriggerPage({ id }: { id: string }) {
|
||||
<>
|
||||
<Chip tone="brand">{TRIGGER_KIND_LABEL[triggerKindOf(t.spec)]}</Chip>
|
||||
<Chip ico="boxes">调起 {t.processPackageId}</Chip>
|
||||
<Chip tone={t.enabled ? 'ok' : 'mute'} ico="power">
|
||||
{t.enabled ? '起单中' : '已停用'}
|
||||
</Chip>
|
||||
{t.confirm.required && <Chip tone="warn">命中须人工确认</Chip>}
|
||||
</>
|
||||
}
|
||||
desc="触发器与流程包平级——不入包结构、不随包版本,增删启停不改任何版本。规则在设计中心改,开关在运营中心拨。"
|
||||
acts={[
|
||||
{
|
||||
label: t.enabled ? '停用' : '启用',
|
||||
ico: 'power',
|
||||
danger: t.enabled,
|
||||
ask: {
|
||||
title: `${t.enabled ? '停用' : '启用'}触发器:${t.name}`,
|
||||
note: <Note>启停不改任何版本;已起的单不受影响。</Note>,
|
||||
impact: {
|
||||
who: t.enabled ? '此后不再自动起单,已在途工作流不受影响' : '此后按规则自动起单',
|
||||
undo: '可随时反向切换',
|
||||
when: '确认后即时',
|
||||
},
|
||||
okLabel: t.enabled ? '确认停用' : '确认启用',
|
||||
onOk: () => toggle.mutate(!t.enabled),
|
||||
},
|
||||
},
|
||||
]}
|
||||
desc="设计中心只负责设计:这里改的是规则与装配。启停开关、要不要人工确认、谁来确认,在运营中心 · 工作流的运行配置里。"
|
||||
/>
|
||||
<Card ico="settings" title="起单规则">
|
||||
<Rows
|
||||
|
||||
@@ -73,7 +73,8 @@ describe('legacyGovTarget —— 旧 #/gov 深链一条不死', () => {
|
||||
});
|
||||
|
||||
it('运行态区改写到 /ops', () => {
|
||||
expect(legacyGovTarget('#/gov/tasks/board')).toBe('/ops#/ops/tasks/board');
|
||||
// 看板已迁「工作流」区(2026-07-30):工作流是流程包的运行时,一级看板归它
|
||||
expect(legacyGovTarget('#/gov/tasks/board')).toBe('/ops#/ops/runs/board');
|
||||
});
|
||||
|
||||
it('已删的区落到对应新家', () => {
|
||||
|
||||
@@ -43,8 +43,9 @@ export const GOV_ZONES = [
|
||||
'archive',
|
||||
'models',
|
||||
'testbed',
|
||||
// 运营中心
|
||||
// 运营中心 —— 区名即运行时:工作流是流程包的运行时,任务是活动的运行时
|
||||
'cockpit',
|
||||
'runs',
|
||||
'tasks',
|
||||
'people',
|
||||
'ledgers',
|
||||
@@ -64,10 +65,11 @@ export const ZONES: ReadonlyArray<{ key: GovZone; label: string; icon: string }>
|
||||
{ key: 'models', label: '大模型', icon: 'cpu' },
|
||||
{ key: 'testbed', label: '测试台', icon: 'flask-conical' },
|
||||
{ key: 'cockpit', label: '驾驶舱', icon: 'gauge' },
|
||||
{ key: 'tasks', label: '任务', icon: 'activity' },
|
||||
{ key: 'runs', label: '工作流', icon: 'activity' },
|
||||
{ key: 'tasks', label: '任务', icon: 'list-checks' },
|
||||
{ key: 'people', label: '员工', icon: 'users' },
|
||||
{ key: 'ledgers', label: '台账', icon: 'table' },
|
||||
{ key: 'knowledge', label: '知识库', icon: 'layers' },
|
||||
{ key: 'knowledge', label: '数据', icon: 'layers' },
|
||||
{ key: 'system', label: '系统', icon: 'settings' },
|
||||
];
|
||||
|
||||
@@ -92,16 +94,22 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
|
||||
desc: '行数据 · 版本 · 引用——域的结构在设计中心改,这里做运营动作。域即页签,随数据域发布生长:设计侧一发布新域,台账里自动多一个域页签。',
|
||||
},
|
||||
knowledge: {
|
||||
title: '知识库',
|
||||
title: '数据',
|
||||
icon: 'layers',
|
||||
accent: 'linear-gradient(140deg,#f14ba9,#c92d86)',
|
||||
desc: '活动记忆四层 + 技能资产 + 候选增量的回放与放行。记忆的终点是成为模型——候选增量必经回放与人放行,这里是那道人工放行口。',
|
||||
desc: '运行沉淀的数据资产:活动记忆四层 + 技能资产 + 候选增量的回放与放行。记忆的终点是成为模型——候选增量必经回放与人放行,这里是那道人工放行口。',
|
||||
},
|
||||
runs: {
|
||||
title: '工作流',
|
||||
icon: 'activity',
|
||||
accent: 'linear-gradient(140deg,#3370ff,#245bdb)',
|
||||
desc: '流程包的运行时:在途工作流的监控,和每个包的运行配置——起单开关、命中后要不要人工确认、谁来确认、每个节点归谁负责。设计中心定义流程长什么样,这里管它今天怎么跑。',
|
||||
},
|
||||
tasks: {
|
||||
title: '任务管理',
|
||||
icon: 'activity',
|
||||
icon: 'list-checks',
|
||||
accent: 'linear-gradient(140deg,#3370ff,#245bdb)',
|
||||
desc: '运行态:任务与工作流两级运行时实例的在途监控、干预与控制——任务 = 活动被调起的一次运行,恰有一名负责人;工作流 = 流程包的一次运行,状态由流内各任务聚合投影而来。审批点卡到审批人工位,是流程层的人裁节点、不是任务。控制事件三种语义:暂停可恢复、叫停归负责人、终止级联作废。治理不改定义。',
|
||||
desc: '活动的运行时:任务 = 活动被调起的一次运行,恰有一名负责人。这里做任务与审批单的在途监控、干预与控制——暂停可恢复、叫停归负责人、终止级联作废。工作流一级去「工作流」区看。',
|
||||
},
|
||||
people: {
|
||||
title: '员工管理',
|
||||
@@ -181,11 +189,13 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
|
||||
tools: [{ key: 'catalog', label: '能力目录', ico: 'wrench' }],
|
||||
data: [{ key: 'domains', label: '数据对象', ico: 'database' }],
|
||||
cockpit: [{ key: 'dash', label: '驾驶舱', ico: 'gauge' }],
|
||||
runs: [
|
||||
{ key: 'board', label: '在途工作流', ico: 'activity' },
|
||||
{ key: 'conf', label: '运行配置', ico: 'settings' },
|
||||
],
|
||||
tasks: [
|
||||
{ key: 'board', label: '运行看板', ico: 'activity' },
|
||||
{ key: 'list', label: '任务', ico: 'list-checks' },
|
||||
{ key: 'apr', label: '审批单', ico: 'shield-check' },
|
||||
{ key: 'trg', label: '起单开关', ico: 'zap' },
|
||||
{ key: 'todo', label: '需处置', ico: 'flag' },
|
||||
],
|
||||
people: [
|
||||
|
||||
@@ -24,10 +24,10 @@ export const SURFACE_META: Readonly<Record<Surface, { path: string; brand: strin
|
||||
},
|
||||
};
|
||||
|
||||
/** 区归属:设计九区 + 运营六区,恰好铺满 `GOV_ZONES`(由测试守住) */
|
||||
/** 区归属:设计九区 + 运营七区,恰好铺满 `GOV_ZONES`(由测试守住) */
|
||||
export const SURFACE_ZONES: Readonly<Record<Surface, readonly GovZone[]>> = {
|
||||
design: ['blueprint', 'triggers', 'workflows', 'library', 'tools', 'data', 'archive', 'models', 'testbed'],
|
||||
ops: ['cockpit', 'tasks', 'people', 'ledgers', 'knowledge', 'system'],
|
||||
ops: ['cockpit', 'runs', 'tasks', 'people', 'ledgers', 'knowledge', 'system'],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -66,6 +66,8 @@ function isGovZone(value: string): value is GovZone {
|
||||
* 精确到页签的先匹配 —— 记忆的两个旧页签各归其位(度量升驾驶舱,活动记忆归知识库)。
|
||||
*/
|
||||
const LEGACY_MOVES: Readonly<Record<string, string>> = {
|
||||
'tasks/board': 'ops/runs/board',
|
||||
'tasks/trg': 'ops/runs/conf',
|
||||
'workflows/rules': 'design/triggers/rules',
|
||||
'testbed/inbound': 'design/testbed/run',
|
||||
'system/llm': 'design/models/llm',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- 触发确认链路(2026-07-30):命中不等于必起 —— 触发器可勾「需人工确认」,
|
||||
-- 命中后先发系统消息到指定人工作台,他点「确认起单」流程才起。
|
||||
-- 这两列是**运营配置**:设计面的 upsert 不写它们,只有运营口(setConfirm)动。
|
||||
alter table sys.trigger add column confirm_required boolean not null default false;
|
||||
alter table sys.trigger add column confirm_staff_id text;
|
||||
|
||||
-- 待确认单:命中但等人点头的那一票。装配好的调用入参存在这里 ——
|
||||
-- 确认那一刻直接起单,不重新装配(事件早已过去,重抽可能抽不到了)。
|
||||
create table sys.trigger_pending (
|
||||
id text primary key,
|
||||
trigger_id text not null,
|
||||
package_id text not null,
|
||||
-- 确认人快照:发消息那一刻的指定人;确认路由按它认门
|
||||
staff_id text not null,
|
||||
subject text not null,
|
||||
data jsonb not null,
|
||||
source jsonb not null,
|
||||
-- 来件预览(from/subject/body 节选)—— 消息上带内容,人不用回头翻邮箱
|
||||
preview jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
consumed_at timestamptz,
|
||||
workflow_id text
|
||||
);
|
||||
create index trigger_pending_open_idx on sys.trigger_pending (trigger_id) where consumed_at is null;
|
||||
|
||||
-- 节点负责人(运营配置):流程包的哪个节点归谁 —— 起单时垫在显式指派之下、轮值之上。
|
||||
create table sys.node_owner (
|
||||
package_id text not null,
|
||||
node_id text not null,
|
||||
staff_id text not null,
|
||||
primary key (package_id, node_id)
|
||||
);
|
||||
@@ -134,6 +134,13 @@
|
||||
"when": 1785130113173,
|
||||
"tag": "0018_tool_display_name",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1785130114173,
|
||||
"tag": "0019_trigger_confirm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -170,3 +170,14 @@ intel-archive)只有契约面,入口产物是抛「未实现」的占位 ——
|
||||
第三个入参,api:check 报「公开契约无变化 ✔」。名单变化(增删导出)照样能拦住。
|
||||
要补得让契约面也收 domain 的 .d.ts,代价是全部纯函数签名进快照、日常改动噪声变大;
|
||||
在那之前:**改纯函数签名时手动同步 CONTRACT.md**,别指望闸提醒。
|
||||
|
||||
## 触发链路 · 邮件分类 Agent(2026-07-30)
|
||||
|
||||
用户定的触发逻辑五步:接收邮件 → **分类 Agent 识别消息类别** → 命中 RFQ 事件 →
|
||||
(可选)系统消息到指定人工作台 → 工作台确认起单。第 ④⑤ 步(确认链路)本轮已落地
|
||||
(`sys.trigger_pending` + 消息 `eventRef` + 幂等确认);第 ② 步的「分类」目前由
|
||||
**通道抽键正则 + 触发器 S-FEEL 规则**代位 —— 判得了「主题带不带询价/RFQ」,判不了语义类别。
|
||||
|
||||
升级路径:通道入站归一后挂一个 LLM 分类工具(工具箱五件之 classify,经模型网关一缝),
|
||||
产出 `messageCategory` 进事件载荷,触发器规则改判 `messageCategory = "rfq"`。
|
||||
前置:模型实例已绑定(大模型区)、分类样例集与回放审。不阻塞现有 RFQ 流 —— 正则今天够用。
|
||||
|
||||
+23
-7
@@ -83,8 +83,9 @@
|
||||
|
||||
```
|
||||
◉ 驾驶舱(首页)
|
||||
── 固定 ──
|
||||
任务
|
||||
── 固定(区名即运行时) ──
|
||||
工作流 ← 流程包的运行时
|
||||
任务 ← 活动的运行时
|
||||
员工
|
||||
── 台账(动态,随数据域长) ──
|
||||
quote_intake
|
||||
@@ -92,10 +93,14 @@ quote_pricing
|
||||
…
|
||||
shared
|
||||
── 固定 ──
|
||||
知识库
|
||||
数据 ← 运行沉淀:活动记忆 + 技能资产
|
||||
系统
|
||||
```
|
||||
|
||||
**命名纪律(2026-07-30)**:运营中心的区名就是运行时对象 ——
|
||||
工作流是流程包的运行时,任务是活动的运行时,台账是数据域的运行时。
|
||||
设计中心只负责设计,运营中心管细节(启停、确认、负责人都在运营侧配)。
|
||||
|
||||
> 落地实测:域的实际粒度是**活动域**(`quote_intake` / `quote_pricing` / `quote_sourcing` /
|
||||
> `quote_negotiate` / `quote_alternative`)+ `shared`,不是本文起初设想的「报价域 / 共享域」。
|
||||
> 决策不变(域 = 导航项),只是数量比示意图多。域名是复合键,左轨按下划线断行呈现。
|
||||
@@ -115,10 +120,21 @@ shared
|
||||
|
||||
| 位 | 内容 |
|
||||
|---|---|
|
||||
| **任务** | 现任务管理区平移:运行看板 / 任务 / 审批单 / 需处置。**触发器的处置并入**:启停开关、近 30 天起单、死信重投 —— 定义在设计中心,开关在这里 |
|
||||
| **员工** | 现员工区平移(员工 / 岗位授权),**补上工作组管理**(ADR-011 欠账:建组 / 改组 —— 起单时的岗位绑定) |
|
||||
| **知识库** | 活动记忆四层 + 技能资产 + 候选增量的回放与放行。"记忆的终点是成为模型"这条进化管线的**人工放行口**在这里 |
|
||||
| **系统** | LLM 实例 / 通道实例,现系统区平移 |
|
||||
| **工作流** | 流程包的运行时,两页签:**在途**(工作流看板,从任务区迁入)+ **运行配置**(每包:触发器起单开关 / 命中后要不要人工确认 / 谁来确认;节点负责人 —— 显式指派 > 运营配置 > 轮值) |
|
||||
| **任务** | 活动的运行时:任务 / 审批单 / 需处置。看板与起单开关已迁「工作流」区 |
|
||||
| **员工** | 员工 / 岗位授权 / 工作组(起单时的岗位绑定) |
|
||||
| **数据** | 运行沉淀的数据资产:活动记忆四层 + 技能资产 + 候选增量的回放与放行。"记忆的终点是成为模型"这条进化管线的**人工放行口**在这里 |
|
||||
| **系统** | 通道实例(模型实例已独立成设计中心「大模型」区) |
|
||||
|
||||
### 触发确认链路(2026-07-30 定)
|
||||
|
||||
事件触发的完整五步:**① 系统接收邮件 → ② 分类识别消息类别 → ③ 命中 RFQ 询价事件 →
|
||||
④(可选,触发器配置打勾)系统消息送指定人工作台,带邮件内容 → ⑤ 工作台点「确认起单」,流程才起。**
|
||||
|
||||
- 打勾与指定人在**运营中心 · 工作流 · 运行配置**里配,设计中心一个字不动;
|
||||
- 确认是幂等的:两个人同时点、一个人点两次,都只起一条流;
|
||||
- 登记触发不走确认 —— 登记那一下本身就是人在确认;
|
||||
- 第 ② 步现由通道抽键 + 触发器 S-FEEL 规则代位,邮件分类 Agent 是升级项(见 debt)。
|
||||
|
||||
### 动态位:台账 ×N
|
||||
|
||||
|
||||
@@ -524,6 +524,35 @@ export const listApprovalsRoute = {
|
||||
* 起一条流(测试台专用):**带节点指派**把整条流定向到指定账号 ——
|
||||
* 真实任务照旧落到那个人的工作台,测试台只管起单与观测。
|
||||
*/
|
||||
/**
|
||||
* 触发器的**运营配置**:要不要人工确认、谁来确认 —— 与设计面的 upsert 分口,
|
||||
* 设计动规则不碰这两项,运营动这两项不碰规则。
|
||||
*/
|
||||
export const setTriggerConfirmRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/triggers/:id/confirm',
|
||||
params: z.object({ id: z.string() }),
|
||||
body: z.object({ required: z.boolean(), staffId: z.string().optional() }),
|
||||
output: z.object({ ok: z.boolean() }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 节点负责人(运营配置):流程包的哪个节点归谁 —— 显式指派 > 这里 > 轮值 */
|
||||
export const listNodeOwnersRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/packages/:id/owners',
|
||||
params: z.object({ id: z.string() }),
|
||||
output: z.object({ owners: z.array(z.object({ nodeId: z.string(), staffId: z.string() })) }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
export const setNodeOwnerRoute = {
|
||||
method: 'PUT',
|
||||
path: '/api/gov/packages/:id/owners/:nodeId',
|
||||
params: z.object({ id: z.string(), nodeId: z.string() }),
|
||||
/** null = 撤掉运营指定,回到轮值 */
|
||||
body: z.object({ staffId: z.string().nullable() }),
|
||||
output: z.object({ ok: z.boolean() }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
export const testRunRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/testbed/run',
|
||||
|
||||
@@ -186,6 +186,13 @@ export const systemMessageSchema = z.object({
|
||||
supersededBy: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
/**
|
||||
* 待确认起单(触发确认链路):触发器命中但勾了「需人工确认」——
|
||||
* 这条消息带着那一票,`confirm.consumed` 前工作台上有一枚「确认起单」钮。
|
||||
*/
|
||||
confirm: z
|
||||
.object({ pendingId: z.string(), consumed: z.boolean(), workflowId: z.string().optional() })
|
||||
.optional(),
|
||||
});
|
||||
export type SystemMessageView = z.infer<typeof systemMessageSchema>;
|
||||
|
||||
@@ -211,6 +218,14 @@ export const answerMessageRoute = {
|
||||
output: z.object({ ok: z.boolean() }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/** 确认起单 —— 幂等:已被确认过就如实说,并回那条流的号,绝不起第二条 */
|
||||
export const confirmTriggerPendingRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/desk/trigger-pending/:id/confirm',
|
||||
params: z.object({ id: z.string() }),
|
||||
output: z.object({ workflowId: z.string(), alreadyStarted: z.boolean() }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
export type ListMessagesOutput = z.infer<typeof listMessagesRoute.output>;
|
||||
|
||||
/* ── 纪要:工作台五要素里唯一的主动动作 ─────────────────────────────── */
|
||||
|
||||
@@ -278,6 +278,15 @@ export const triggerConfigSchema = z.object({
|
||||
processPackageId: processPackageIdSchema,
|
||||
/** 起单载荷装配:事件信封与载荷字段 → 调用接口的 data 字段;缺必填即起单失败入死信 */
|
||||
mapping: z.array(fieldMappingSchema).default([]),
|
||||
/**
|
||||
* 人工确认 —— **运营配置**(运营中心 · 工作流里勾),不随设计动:
|
||||
* 勾了,命中后不直接起单,先发带来件内容的系统消息到指定人工作台,
|
||||
* 他点「确认起单」流程才起。登记触发不适用 —— 登记那一下本身就是人在确认。
|
||||
* 设计面的 upsert **不写**这两项(那会把运营的勾抹掉),只有运营口(setConfirm)动它。
|
||||
*/
|
||||
confirm: z
|
||||
.object({ required: z.boolean().default(false), staffId: staffIdSchema.optional() })
|
||||
.default({ required: false }),
|
||||
/**
|
||||
* 判别式取**四个互斥值**:周期触发的两型(日历 / 数据日历)在此展开 ——
|
||||
* `TRIGGER_KINDS` 仍是三类分类学(周期 / 事件 / 登记),两者以 `triggerKindOf()` 对应。
|
||||
|
||||
Reference in New Issue
Block a user