This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/packages/contracts/src/api/workbench.ts
T
xcsandClaude Fable 5 f3397a60ef E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排
活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2):
- 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进
  S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视
- 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo,
  计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标)
- 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源,
  正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填
- 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键;
  标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段
- 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数
- 阶段刻度对齐文档 7 步;起单样例预填满

基座侧:
- 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY
  广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻
- 多行 write 整批先验后写(dryRun),被拒 = 一行都没落
- 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors)
- 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行)
- 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端
- 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸
- 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组;
  JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖
- 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01

契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo,
objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。
1700 测 / typecheck / lint / depcruise / api:check 绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
2026-08-25 01:23:30 +08:00

639 lines
26 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),
/**
* 活动**自己**的阶段进度 —— 进度条读的是它。
*
* 从前进度条读的是上面那对序位:A1 在七节点流里是 1/7,一开跑就画成 14%,
* 跑到收口还是 14%(节点没换,序位当然不动);单活动试跑更荒唐 —— 恒 1/1,
* 起单那一刻就满格。序位答的是「这单在整条流的哪一环」,不是「这单办到哪了」。
*
* 活动没声明阶段就没有这个键:界面照此不画进度条,不编一个出来。
*/
phase: z
.object({ index: z.number().int().min(1), total: z.number().int().min(1), name: z.string() })
.optional(),
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,
}),
),
/**
* 沟通记录 —— 跟客户/供应商往来的**原件**,按单归档。
* 进来的那一封是开案的那份料(活动的入参就是它);出去的是放行后系统真发的件。
*/
comms: z
.array(
z.object({
id: z.string(),
at: z.string(),
direction: z.enum(['in', 'out']),
channel: z.string(),
party: z.string(),
subject: z.string(),
body: z.string(),
attachments: z.array(
z.object({
name: z.string(),
size: z.number().int().optional(),
ref: z.string().optional(),
/** 基座读出来的说明:读出多少字 / 为什么读不出 —— 界面照抄,不改写 */
note: z.string().optional(),
readable: z.boolean().optional(),
}),
),
note: z.string(),
/**
* **未读**(2026-07-31)——「看没看过」由人按按钮说了算,不由打开页签自动清。
* 自动清等于替人宣布「你看过了」,而他可能只是路过那个页签。
*/
unread: z.boolean().default(false),
}),
)
.default([]),
workOrders: z.array(workOrderSchema),
/**
* **答过的决策卡,答完那一刻的报告快照**(2026-08-21)。
*
* `decide` 是唯一能循环的中间验收机制(`issueAcceptance` 一个任务只能发一次),
* 它答完不产生工单行 —— 工作成果那一屏要看见这些报告,得走这条**单独的**口:
* 混进 `workOrders` 会连累别处「`mode==='acceptance'` 恒是终局放行卡」的假设
* (如 `signed` 那处「字签下了正在落地」的判断)。呈现时前端把两份列表并显即可。
*/
decidedDeliverables: z.array(workOrderSchema).default([]),
/**
* **这一单产出的资料文件** —— 工作成果里与定稿报告并列的一摞(2026-08-23)。
*
* 它不是工单快照:工单是「叫了人办的一件事」,资料是**办出来的东西本身**
* (白皮书 / 对照表 / 落地页)。两者混进一摞会让「几份成果」这个数说不清。
* 在办期间来自台账现造,放行之后来自存档 —— 两处都按 `address + key` 认,
* 点开走的是同一条路(`openAssetFileRoute`)。
*/
assetFiles: z
.array(
z.object({
/** 台账地址 `域.对象` */
address: z.string(),
/** 主键(多列主键用 `列=值` 以 `,` 连)—— 与 `data-file` 里那一串同一形状 */
key: z.string(),
title: z.string(),
/** md | html | docx | xlsx */
format: z.string(),
name: z.string(),
/** 已存档的带上时刻;在办中的没有 */
at: z.string().optional(),
archived: z.boolean(),
}),
)
.default([]),
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(),
/**
* 这一条**是谁干的** —— 日志只认三档:`owner` 负责人 · `agent` 智能体 · `system` 系统。
*
* 与 `side`(五种归侧)分工不同:那一维是**度量**用的(自主率要把「等外面」
* 与「机器干的」分开);这一维是**呈现**用的 —— 人扫这一屏只问
* 「这一步是我办的、AI 办的,还是流程自己走到的」,三档各一个颜色。
*/
who: z.enum(['owner', 'agent', 'system']),
/** 类型章:**四字短语**,与对话区同词(触发事件 / 阶段结论 / 数据录入 / 员工确认 …) */
action: z.string(),
/** 内容,**带主语**:`任务助手:…` / `柴浩:…` —— 日志不写没头脑的流水账 */
title: z.string(),
summary: z.string().optional(),
/**
* **展开才看的细节 —— 一段一句人话**(2026-08-03 用户:「不要写二级条目,
* 直接正文描述完」):凭什么算这一步办完了、那一笔落下哪几行、被拒拒在哪儿。
* 每句自带主语与语境,单拎出来也读得通;不摆系统内的英文名。
* 摘不出东西就是空数组 —— 前端据此**不给展开钮**,没有细节不摆空抽屉。
*/
detail: z.array(z.string()).default([]),
}),
),
});
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 markCommReadRoute = {
method: 'POST',
path: '/api/desk/comms/:commId/read',
params: z.object({ commId: z.string() }),
body: z.object({ read: z.boolean() }),
output: z.object({ ok: z.boolean(), unread: z.boolean() }),
} as const satisfies RouteContract;
/**
* **就地改完按下「保存」** —— 把件上那张表写回**本单草稿层**,工单不办结。
*
* 为什么非要立刻落库、而不是攒到提交那一下:件是**服务端投影**。
* 人改了几格,只读那份跟着变了(那是他自己敲的),而由数据算出来的东西
* ——类徽 A/B/C、分布条、结论横幅那条去向——全是渲染那一刻的旧值。
* 于是屏上一半新一半旧:**人把三条料全改成 A,底下的去向还写着「去选型」**
* (用户实测)。那不是没更新,那是界面在撒谎。
*
* 落库之后前端重取任务投影,服务端拿新数据重渲染整份件 —— 算出来的那些跟着一起变,
* 去向也由同一次谓词求值重算。**放行门的位置没变**:写的仍是草稿层,
* 合并进正式版依旧只发生在人签字那一刻。
*
* ── 按**地址**收,不按「当下那张卡的第几块」收(2026-08-02 用户实测逼出来)──────
*
* 载荷从前是 `{workOrderId, values:{'table:0': …}}`:列名由**那张工单的 `blocks[0]`**
* 给出,于是「这份件能不能改」被绑在了「当下开着的那张卡长什么样」上。
* 实测的下场:一单在放行门连拒五拍熔断,引擎发的是一张**没有 blocks 的熔断卡**;
* 而屏上摆着的正是受理记录那份件,闸也在、编辑也进得去 —— 人把那条料的年用量补上、
* 按了保存,前端找不到匹配的块,**一格都没往下送**。他要补的恰恰就是把这一单
* 卡在那儿的那个必填格(判据 `quote_intake_record_missing = 0`),
* 而屏上什么反应都没有 —— 界面提供了一个落不了地的编辑口。
*
* 现在收的是件自己说的话:每张可改的表挂着 `data-edit="域.对象"`,格子挂着
* `data-col="台账列名"` —— 地址与列名**本来就在件里**,不必再绕一趟工单。
* 授权面改由**活动清单的台账声明**把关(不在声明里的地址一概不写),
* 比「当下那张卡碰巧带了哪一块」既严格又稳定。
*
* **改、增、删是同一件事的三种样子**(2026-08-02 用户:「编辑列表应该还能增删行」):
* 人手改本来就是**兜 AI 的错**的那一手 —— 而错不只有「这一格填错了」,
* 还有「这一行根本不该在」(客户点名本轮跳过的料被写了进来)与「漏了一行」。
* 只给改格子,前两种就没有出口:那一单会一路走到放行门被判据拦住,
* 而人明明一眼就看出问题在哪。
*/
export const saveTableRoute = {
method: 'POST',
path: '/api/desk/tasks/:taskId/table',
params: z.object({ taskId: taskIdSchema }),
body: z.object({
/** `{ '域.对象': [ { 台账列名: 值 } ] }` —— 主键列用来认行,不参与改行;台账里没有的行就是**新增** */
tables: z.record(z.string(), z.array(z.record(z.string(), z.string()))),
/**
* **人在件上删掉的那几行** —— 摆主键,一行一条。
*
* 为什么删要单独说,而不是「`tables` 里没有的就当删了」:那种口径把**一次不完整的
* 回传**变成一次删表 —— 件上摆的是不是整张表,服务端看不见(模版绑哪条路径是活动的事)。
* 说出来的删才删:回传缺了什么一律当「这次没提到」,不当「要撤掉」。
*
* 撤的只是**本单草稿层**那一笔(`draftDrop`),正式版一个字都动不了、不抛事件 ——
* 与 Agent 的 `drop` 同一条路子。放行门的位置没变。
*/
drops: z.record(z.string(), z.array(z.record(z.string(), z.string()))).optional(),
}),
output: z.object({
ok: z.boolean(),
rows: z.number().int().min(0),
/**
* **认不出是哪一行、因而没落下的那几行**(2026-08-24 用户实测:
* 「用编辑新增了一个客户,推进到下一步直接就把我手动添加的这一行遗漏掉了」)。
*
* 主键那几格空着就凑不出钥匙,存不住。从前基座一句 `continue` 悄悄跳过、
* 接口照回 `{ok:true}` —— 人看到「保存成功」,东西却不在。
* 仍回 200(别的格子是真落下了),但把没落下的行摆回来:前端红字点名,
* 人补上钥匙再存一次。**保存说成功,就得真的都存下了。**
*/
unkeyed: z
.array(z.object({ address: z.string(), keys: z.array(z.string()), hint: z.string() }))
.optional(),
}),
} as const satisfies RouteContract;
/**
* **点开一份资料文件** —— 件上的资料名指向哪一行,这里就把那一行现造成文件交出去。
*
* 为什么按「台账地址 + 主键」取,而不是给文件发一个 id:资料的正身是台账里那一行
* (标题、正文、要哪种格式都在上头),文件是它的投影。发 id 就等于另存一份,
* 同一份事实两处各存一份 —— 台账改了,那个 id 指的还是旧的。
*
* `raw=1` 交原字节(下载,真 .docx / .xlsx);不带就交 `FileDoc`,前端照它渲。
*/
export const openAssetFileRoute = {
method: 'GET',
path: '/api/desk/tasks/:taskId/file',
params: z.object({ taskId: taskIdSchema }),
query: z.object({
/** `域.对象` —— 必须是活动清单里声明过 `file` 段的那张表(授权面) */
address: z.string().min(3),
/** 主键值(多列主键用 `列=值` 以 `,` 连;单列主键直接给值) */
key: z.string().min(1),
raw: z.string().optional(),
}),
output: z.object({ file: fileDocSchema, fileName: z.string(), format: z.string() }),
} 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>;