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

**触发确认链路**(用户口述五步的第 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
@@ -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, {
+2
View File
@@ -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
View File
@@ -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[]>;
};
+27
View File
@@ -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 = {
+59
View File
@@ -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";
+22
View File
@@ -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;
+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) => {
+3
View File
@@ -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 },
+4
View File
@@ -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 切面):
* 语义、留痕与副作用编排住在运行时实现里,薄壳只转发。
+4
View File
@@ -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):编排校验与起单都要它 */
+6
View File
@@ -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);
});
});
+27 -1
View File
@@ -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);
},
};
}
+27
View File
@@ -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 装配) */
+2
View File
@@ -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,
+15
View File
@@ -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>}
</>
);
}
+5
View File
@@ -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>
);
}
+1 -1
View File
@@ -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 ?? [];
+1 -1
View File
@@ -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('已删的区落到对应新家', () => {
+19 -9
View File
@@ -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: [
+4 -2
View File
@@ -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',
+32
View File
@@ -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)
);
+7
View File
@@ -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
}
]
}
+11
View File
@@ -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
View File
@@ -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
+29
View File
@@ -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',
+15
View File
@@ -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()` 对应。