**触发确认链路**(用户口述五步的第 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
436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
/**
|
|
* 工作台路由契约(编码规范 §8.1 / §9.1)。
|
|
* 工作台五要素:任务 / 审批 / 消息 / 纪要 / 汇报 —— 同一份过程事实的五种投影。
|
|
*/
|
|
import { z } from 'zod';
|
|
import type { RouteContract } from './http.ts';
|
|
import { sessionSchema } from '../org.ts';
|
|
import { chatMessageSchema, deliverableSchema, fileDocSchema, formFieldSchema, stageSchema, viewBlockSchema, workOrderSchema } from '../card.ts';
|
|
import { attributionSideSchema, taskRuntimeStateSchema, terminalStateSchema, workflowStateSchema } from '../terminal.ts';
|
|
import { staffIdSchema, taskIdSchema } from '../id.ts';
|
|
|
|
/** 我是谁 —— 无会话即 401,前端据此落登录页(ADR-010) */
|
|
export const sessionRoute = {
|
|
method: 'GET',
|
|
path: '/api/session',
|
|
output: sessionSchema,
|
|
} as const satisfies RouteContract;
|
|
|
|
/**
|
|
* 登录 —— 单组织 cookie session(编码规范 §4 / ADR-010)。
|
|
* 成功即种 `jcai_session`(HttpOnly);失败只回一句话,不区分账号不存在还是口令错。
|
|
*/
|
|
export const loginRoute = {
|
|
method: 'POST',
|
|
path: '/api/login',
|
|
body: z.object({ account: z.string().min(1), password: z.string().min(1) }),
|
|
output: sessionSchema,
|
|
} as const satisfies RouteContract;
|
|
|
|
/** 登出 —— 服务端会话立刻作废,不等 cookie 到期 */
|
|
export const logoutRoute = {
|
|
method: 'POST',
|
|
path: '/api/logout',
|
|
output: z.object({ ok: z.boolean() }),
|
|
} as const satisfies RouteContract;
|
|
|
|
export const healthRoute = {
|
|
method: 'GET',
|
|
path: '/api/health',
|
|
output: z.object({
|
|
ok: z.boolean(),
|
|
version: z.string(),
|
|
db: z.enum(['up', 'down']),
|
|
}),
|
|
} as const satisfies RouteContract;
|
|
|
|
/* ── 任务要素:四区(任务列表 / 标题区 / 工作区 / 对话区) ─────────────── */
|
|
|
|
/** 任务卡 —— 清单里一条 = 一个活动的一次运行,不是一整条工作流 */
|
|
export const taskCardSchema = z.object({
|
|
id: taskIdSchema,
|
|
activity: z.string(),
|
|
title: z.string(),
|
|
runtime: taskRuntimeStateSchema,
|
|
terminal: terminalStateSchema.optional(),
|
|
/** 它在工作流上的序位 */
|
|
position: z.number().int().min(1),
|
|
total: z.number().int().min(1),
|
|
workflowSubject: z.string(),
|
|
workflowState: workflowStateSchema,
|
|
startedAt: z.string(),
|
|
});
|
|
export type TaskCard = z.infer<typeof taskCardSchema>;
|
|
|
|
export const listTasksRoute = {
|
|
method: 'GET',
|
|
path: '/api/desk/tasks',
|
|
output: z.object({ tasks: z.array(taskCardSchema) }),
|
|
} as const satisfies RouteContract;
|
|
|
|
/**
|
|
* 流程链上的一环 —— 「一条 = 一个活动的一次运行」之外,人还要看见自己在整条流程的哪儿。
|
|
* 别人负责的节点也在链上(只读),但不进任务清单。
|
|
*/
|
|
export const chainNodeSchema = z.object({
|
|
nodeId: z.string(),
|
|
label: z.string(),
|
|
kind: z.enum(['activity', 'approval']),
|
|
/** 谁做:姓名 · 岗位;岗位未落到具体人时只给岗位名 */
|
|
ownerName: z.string(),
|
|
ownerRole: z.string(),
|
|
state: z.enum(['done', 'current', 'todo']),
|
|
});
|
|
export type ChainNode = z.infer<typeof chainNodeSchema>;
|
|
|
|
/** 任务详情 —— 工作区与对话区的服务端投影(前端零过程状态,刷新即重建) */
|
|
export const taskDetailSchema = z.object({
|
|
card: taskCardSchema,
|
|
goal: z.string(),
|
|
/** 本单负责人 —— 标题区落款与报告署名 */
|
|
owner: z.object({ name: z.string(), role: z.string() }),
|
|
/** 整条流程的节点链(含他人节点,只读) */
|
|
chain: z.array(chainNodeSchema),
|
|
budget: z.object({
|
|
steps: z.string(),
|
|
tokens: z.string(),
|
|
activeMinutes: z.string(),
|
|
humanCalls: z.string(),
|
|
}),
|
|
stages: z.array(stageSchema),
|
|
deliverables: z.array(deliverableSchema),
|
|
/**
|
|
* 工作成果 = 一组**文件**,大部分是 HTML 汇报报告。
|
|
* 一行一份、点进去看;文件本体随投影一并下发,前端不另取。
|
|
*/
|
|
results: z.array(
|
|
z.object({
|
|
label: z.string(),
|
|
desc: z.string(),
|
|
at: z.string().optional(),
|
|
file: fileDocSchema,
|
|
}),
|
|
),
|
|
workOrders: z.array(workOrderSchema),
|
|
chat: z.array(chatMessageSchema),
|
|
view: z.array(viewBlockSchema),
|
|
/** 活动推送的视图 HTML —— 容器沙箱渲染,无脚本执行权(AM 07) */
|
|
viewHtml: z.string().optional(),
|
|
log: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
at: z.string(),
|
|
side: attributionSideSchema,
|
|
sideLabel: z.string(),
|
|
title: z.string(),
|
|
summary: z.string().optional(),
|
|
}),
|
|
),
|
|
});
|
|
export type TaskDetail = z.infer<typeof taskDetailSchema>;
|
|
|
|
export const taskDetailRoute = {
|
|
method: 'GET',
|
|
path: '/api/desk/tasks/:taskId',
|
|
params: z.object({ taskId: taskIdSchema }),
|
|
output: taskDetailSchema,
|
|
} as const satisfies RouteContract;
|
|
|
|
/** 回传工单 —— 与后端回传校验同 schema(§9.2) */
|
|
export const replyWorkOrderRoute = {
|
|
method: 'POST',
|
|
path: '/api/desk/work-orders/reply',
|
|
body: z.object({ reply: z.record(z.string(), z.unknown()) }),
|
|
output: z.object({ ok: z.boolean(), producesCompletion: z.boolean() }),
|
|
} as const satisfies RouteContract;
|
|
|
|
/**
|
|
* 对话卡回传(决策 / 补全 / 评价)—— 与后端回传校验**同 schema**。
|
|
* 对话流是一切回环输入的唯一入口(OS 4.1):卡就长在对话流上,答也就地答。
|
|
*/
|
|
export const replyChatCardRoute = {
|
|
method: 'POST',
|
|
path: '/api/desk/chat-cards/reply',
|
|
body: z.object({ reply: z.record(z.string(), z.unknown()) }),
|
|
output: z.object({ ok: z.boolean() }),
|
|
} as const satisfies RouteContract;
|
|
|
|
/** 负责人的自由输入 —— 人事件的第二形态 */
|
|
export const sayRoute = {
|
|
method: 'POST',
|
|
path: '/api/desk/tasks/:taskId/say',
|
|
params: z.object({ taskId: taskIdSchema }),
|
|
body: z.object({ text: z.string().min(1) }),
|
|
output: chatMessageSchema,
|
|
} as const satisfies RouteContract;
|
|
|
|
/** 消息要素 */
|
|
/** 消息是**信息**,不产生责任;跨任务征答卡以系统消息送达,**就地作答** */
|
|
export const systemMessageSchema = z.object({
|
|
id: z.string(),
|
|
kind: z.string(),
|
|
title: z.string(),
|
|
body: z.string(),
|
|
at: z.string(),
|
|
read: z.boolean(),
|
|
/** 跨任务征答:卡本体与来源任务 —— 作答权限只及卡面,不开放任务对话流 */
|
|
card: z
|
|
.object({
|
|
cardId: z.string(),
|
|
sourceTaskId: z.string(),
|
|
/** 按 schema 渲染的表单字段;与后端回传校验同 schema */
|
|
fields: z.array(formFieldSchema).default([]),
|
|
ask: z.string().default(''),
|
|
answered: z.boolean(),
|
|
rejectedReason: z.string().optional(),
|
|
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>;
|
|
|
|
export const listMessagesRoute = {
|
|
method: 'GET',
|
|
path: '/api/desk/messages',
|
|
output: z.object({ messages: z.array(systemMessageSchema) }),
|
|
} as const satisfies RouteContract;
|
|
|
|
export const markMessageReadRoute = {
|
|
method: 'POST',
|
|
path: '/api/desk/messages/:messageId/read',
|
|
params: z.object({ messageId: z.string() }),
|
|
output: z.object({ ok: z.boolean() }),
|
|
} as const satisfies RouteContract;
|
|
|
|
/** 就地作答 —— 回传经统一入口写回来源任务,调用与回传全程在任务侧留痕 */
|
|
export const answerMessageRoute = {
|
|
method: 'POST',
|
|
path: '/api/desk/messages/:messageId/answer',
|
|
params: z.object({ messageId: z.string() }),
|
|
body: z.object({ answer: z.record(z.string(), z.unknown()) }),
|
|
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>;
|
|
|
|
/* ── 纪要:工作台五要素里唯一的主动动作 ─────────────────────────────── */
|
|
|
|
export const minuteResolutionSchema = z.object({
|
|
id: z.string(),
|
|
text: z.string(),
|
|
ownerId: staffIdSchema.optional(),
|
|
state: z.enum(['open', 'registered']),
|
|
workflowId: z.string().optional(),
|
|
registeredById: staffIdSchema.optional(),
|
|
registeredAt: z.string().optional(),
|
|
});
|
|
|
|
export const minuteSchema = z.object({
|
|
id: z.string(),
|
|
title: z.string(),
|
|
body: z.string(),
|
|
authorId: staffIdSchema,
|
|
authorName: z.string(),
|
|
participants: z.array(z.string()),
|
|
occurredOn: z.string(),
|
|
resolutions: z.array(minuteResolutionSchema),
|
|
createdAt: z.string(),
|
|
updatedAt: z.string(),
|
|
});
|
|
export type MinuteView = z.infer<typeof minuteSchema>;
|
|
|
|
export const listMinutesRoute = {
|
|
method: 'GET',
|
|
path: '/api/minutes',
|
|
output: z.object({ minutes: z.array(minuteSchema) }),
|
|
} as const satisfies RouteContract;
|
|
|
|
export const createMinuteRoute = {
|
|
method: 'POST',
|
|
path: '/api/minutes',
|
|
body: z.object({
|
|
title: z.string().min(1),
|
|
body: z.string(),
|
|
occurredOn: z.string().min(1),
|
|
participants: z.array(staffIdSchema).default([]),
|
|
resolutions: z.array(z.object({ text: z.string().min(1), ownerId: staffIdSchema.optional() })).default([]),
|
|
}),
|
|
output: minuteSchema,
|
|
} as const satisfies RouteContract;
|
|
|
|
export const appendResolutionRoute = {
|
|
method: 'POST',
|
|
path: '/api/minutes/:minuteId/resolutions',
|
|
params: z.object({ minuteId: z.string() }),
|
|
body: z.object({ text: z.string().min(1), ownerId: staffIdSchema.optional() }),
|
|
output: minuteSchema,
|
|
} as const satisfies RouteContract;
|
|
|
|
/** 决议 → 登记起单:登记人即来源 */
|
|
export const registerResolutionRoute = {
|
|
method: 'POST',
|
|
path: '/api/minutes/resolutions/:resolutionId/register',
|
|
params: z.object({ resolutionId: z.string() }),
|
|
body: z.object({ processPackageId: z.string().min(1), data: z.record(z.string(), z.unknown()) }),
|
|
output: z.object({ workflowId: z.string(), resolutionId: z.string() }),
|
|
} as const satisfies RouteContract;
|
|
|
|
/* ── 汇报:系统代劳的自动产出 ───────────────────────────────────────── */
|
|
|
|
export const reportPeriodSchema = z.enum(['day', 'week', 'month', 'year']);
|
|
export type ReportPeriodName = z.infer<typeof reportPeriodSchema>;
|
|
|
|
export const REPORT_PERIOD_LABEL: Readonly<Record<ReportPeriodName, string>> = {
|
|
day: '日报',
|
|
week: '周报',
|
|
month: '月报',
|
|
year: '年报',
|
|
};
|
|
|
|
export const reportMetricsSchema = z.object({
|
|
autonomy: z.number(),
|
|
correction: z.number(),
|
|
effectiveAutonomy: z.number(),
|
|
interruption: z.number(),
|
|
breakpoint: z.number(),
|
|
dmnCoverage: z.number(),
|
|
unitCost: z.object({ tokens: z.number(), minutes: z.number(), humanCalls: z.number() }),
|
|
terminalMix: z.record(z.string(), z.number()),
|
|
});
|
|
|
|
export const reportSchema = z.object({
|
|
id: z.string(),
|
|
period: reportPeriodSchema,
|
|
title: z.string(),
|
|
window: z.object({ from: z.string(), to: z.string() }),
|
|
sections: z.array(
|
|
z.object({
|
|
taskId: z.string(),
|
|
title: z.string(),
|
|
activityId: z.string(),
|
|
terminalLabel: z.string().optional(),
|
|
lines: z.array(z.string()),
|
|
}),
|
|
),
|
|
metrics: reportMetricsSchema,
|
|
counts: z.object({ touched: z.number().int(), released: z.number().int(), logLines: z.number().int() }),
|
|
note: z.string().optional(),
|
|
submittedAt: z.string().optional(),
|
|
generatedAt: z.string(),
|
|
});
|
|
export type ReportView = z.infer<typeof reportSchema>;
|
|
|
|
/** 未提交的草案每次取回都重算 —— 汇报是投影,不是账本 */
|
|
export const draftReportRoute = {
|
|
method: 'GET',
|
|
path: '/api/reports/:period',
|
|
params: z.object({ period: reportPeriodSchema }),
|
|
output: reportSchema,
|
|
} as const satisfies RouteContract;
|
|
|
|
export const listReportsRoute = {
|
|
method: 'GET',
|
|
path: '/api/reports',
|
|
output: z.object({ reports: z.array(reportSchema) }),
|
|
} as const satisfies RouteContract;
|
|
|
|
/** 确认即提交,提交后不再改 */
|
|
export const submitReportRoute = {
|
|
method: 'POST',
|
|
path: '/api/reports/:reportId/submit',
|
|
params: z.object({ reportId: z.string() }),
|
|
body: z.object({ note: z.string().optional() }),
|
|
output: reportSchema,
|
|
} as const satisfies RouteContract;
|
|
|
|
export type ListMinutesOutput = z.infer<typeof listMinutesRoute.output>;
|
|
export type ListReportsOutput = z.infer<typeof listReportsRoute.output>;
|
|
|
|
/* ── 审批:工作流层的审批点卡到审批人工位 ───────────────────────────── */
|
|
|
|
export const approvalItemSchema = z.object({
|
|
id: z.string(),
|
|
workflowId: z.string(),
|
|
workflowSubject: z.string(),
|
|
nodeId: z.string(),
|
|
title: z.string(),
|
|
/** 依据快照 —— 调用必附上下文,裸调用不得发出 */
|
|
basis: z.array(z.object({ k: z.string(), v: z.string() })),
|
|
ask: z.string(),
|
|
state: z.enum(['pending', 'passed', 'rejected']),
|
|
/** 驳回走向:声明式,不由临场决定 */
|
|
rejectRoute: z.string(),
|
|
raisedAt: z.string(),
|
|
});
|
|
export type ApprovalItem = z.infer<typeof approvalItemSchema>;
|
|
|
|
export const listApprovalsForMeRoute = {
|
|
method: 'GET',
|
|
path: '/api/approvals',
|
|
output: z.object({ approvals: z.array(approvalItemSchema) }),
|
|
} as const satisfies RouteContract;
|
|
|
|
/** 通过 / 驳回,附依据 —— 驳回必附意见 */
|
|
export const decideApprovalRoute = {
|
|
method: 'POST',
|
|
path: '/api/approvals/:workflowId/:nodeId/decide',
|
|
params: z.object({ workflowId: z.string(), nodeId: z.string() }),
|
|
body: z.object({ verdict: z.enum(['pass', 'reject']), comment: z.string().optional() }),
|
|
output: z.object({ ok: z.boolean(), workflowState: workflowStateSchema }),
|
|
} as const satisfies RouteContract;
|
|
|
|
export type ListApprovalsForMeOutput = z.infer<typeof listApprovalsForMeRoute.output>;
|
|
|
|
/* ── 登记触发:人工起单的统一形态(OS 3.1) ─────────────────────────── */
|
|
|
|
/**
|
|
* 「登记触发 —— 人工起单的统一形态:登记人选择流程包、按调用接口 schema 填写输入
|
|
* (表单由 schema 自动渲染)、提交即起单。」**登记人即来源**。
|
|
*/
|
|
export const listRegistrationTriggersRoute = {
|
|
method: 'GET',
|
|
path: '/api/triggers/registration',
|
|
output: z.object({
|
|
triggers: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
processPackageId: z.string(),
|
|
enabled: z.boolean(),
|
|
/** 调用接口的入参 schema —— 登记表单据此渲染 */
|
|
inputSchema: z.record(z.string(), z.unknown()),
|
|
}),
|
|
),
|
|
}),
|
|
} as const satisfies RouteContract;
|
|
|
|
export const fireRegistrationRoute = {
|
|
method: 'POST',
|
|
path: '/api/triggers/:triggerId/register',
|
|
params: z.object({ triggerId: z.string() }),
|
|
body: z.object({ data: z.record(z.string(), z.unknown()) }),
|
|
output: z.object({
|
|
ok: z.boolean(),
|
|
workflowId: z.string().optional(),
|
|
skipped: z.string().optional(),
|
|
missing: z.array(z.string()).optional(),
|
|
}),
|
|
} as const satisfies RouteContract;
|
|
|
|
export type ListRegistrationTriggersOutput = z.infer<typeof listRegistrationTriggersRoute.output>;
|