forked from ChaiHao/BPOS-MVP
**触发确认链路**(用户口述五步的第 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
874 lines
39 KiB
TypeScript
874 lines
39 KiB
TypeScript
/**
|
|
* 治理台九功能的运行面路由(编码规范 §3)。
|
|
*
|
|
* 「治理台九功能**不设**后端 governance 模块:每个功能是对应模块的管理用例 + 路由组装。」
|
|
* 「治理动作本身留痕入事件流 —— 治理也被治理。」
|
|
*/
|
|
import { sql } from 'drizzle-orm';
|
|
import type { Hono } from 'hono';
|
|
import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';
|
|
import {
|
|
ATTRIBUTION_SIDE_LABEL,
|
|
browseLedgerRoute,
|
|
controlTaskRoute,
|
|
govMetricsRoute,
|
|
govTaskLogRoute,
|
|
govWorkflowRoute,
|
|
listActivitiesRoute,
|
|
listApprovalsRoute,
|
|
listChannelsRoute,
|
|
listActivityMemoryRoute,
|
|
listGovTasksRoute,
|
|
listLedgerObjectsRoute,
|
|
installActivityRoute,
|
|
listProcessPackagesRoute,
|
|
listToolsRoute,
|
|
listTriggersRoute,
|
|
registerToolRoute,
|
|
saveProcessRoute,
|
|
testCurrentRoute,
|
|
testRunRoute,
|
|
testStopRoute,
|
|
traceOfWorkflowRoute,
|
|
setProcessStateRoute,
|
|
reassignTaskRoute,
|
|
resumeTaskRoute,
|
|
staffIdSchema,
|
|
taskIdSchema,
|
|
setNodeOwnerRoute,
|
|
setTriggerConfirmRoute,
|
|
listNodeOwnersRoute,
|
|
toggleTriggerRoute,
|
|
upsertTriggerRoute,
|
|
validateProcessRoute,
|
|
} from '@contracts';
|
|
import { evaluate as evalSfeel } from '@sfeel';
|
|
import { extractFields } from '@modules/channel';
|
|
import { assembleInput, scopeOf } from '@modules/trigger';
|
|
import { InstallRejectedError } from '@modules/activity-registry';
|
|
import type { InstallVerdict } from '@modules/activity-registry';
|
|
import { NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/process';
|
|
import type { Container } from '../container.ts';
|
|
import type { AppEnv } from '../http-env.ts';
|
|
|
|
/** `db.execute` 的行读取 —— Drizzle 的返回型随驱动变,窄化收在这一处 */
|
|
function rowsOf<T>(result: unknown): T[] {
|
|
const bag = result as { rows?: T[] } | T[];
|
|
return Array.isArray(bag) ? bag : (bag.rows ?? []);
|
|
}
|
|
|
|
/**
|
|
* 销毁仿真单 —— 把测试台起的那一条(或指定的一条)**连根拔掉**。
|
|
*
|
|
* 顺序按外键的反方向来:先拆挂在任务上的东西(工单、卡、记忆、运行时、意向、监听),
|
|
* 再拆任务与节点实例,最后拆工作流本身;trace 与事件按工作流清。
|
|
* 用 `source->>'ref' = 'testbed'` 认门:**只动测试台自己起的单**,
|
|
* 生产单一个字都不碰(它们的来源是触发器或纪要登记)。
|
|
*/
|
|
async function destroySimulations(container: Container, workflowId?: string): Promise<number> {
|
|
const scope = workflowId === undefined ? sql`` : sql` and id = ${workflowId}`;
|
|
const wfs = await container.db.execute<{ id: string }>(
|
|
sql`select id from sys.workflow where source->>'ref' = 'testbed'${scope}`,
|
|
);
|
|
const ids = rowsOf<{ id: string }>(wfs).map((r) => r.id);
|
|
if (ids.length === 0) return 0;
|
|
|
|
let taskCount = 0;
|
|
for (const id of ids) {
|
|
const tasks = await container.db.execute<{ id: string }>(sql`select id from sys.task where workflow_id = ${id}`);
|
|
const taskIds = rowsOf<{ id: string }>(tasks).map((r) => r.id);
|
|
taskCount += taskIds.length;
|
|
for (const taskId of taskIds) {
|
|
await container.db.execute(sql`delete from sys.work_order where task_id = ${taskId}`);
|
|
await container.db.execute(sql`delete from sys.chat_card where task_id = ${taskId}`);
|
|
await container.db.execute(sql`delete from sys.chat_message where task_id = ${taskId}`);
|
|
await container.db.execute(sql`delete from sys.task_memory where task_id = ${taskId}`);
|
|
await container.db.execute(sql`delete from sys.task_memory_seal where task_id = ${taskId}`);
|
|
await container.db.execute(sql`delete from sys.task_view where task_id = ${taskId}`);
|
|
await container.db.execute(sql`delete from sys.flow_state where task_id = ${taskId}`);
|
|
await container.db.execute(sql`delete from sys.loop_state where task_id = ${taskId}`);
|
|
await container.db.execute(sql`delete from sys.action_intent where task_id = ${taskId}`);
|
|
await container.db.execute(sql`delete from sys.listener where task_id = ${taskId}`);
|
|
}
|
|
await container.db.execute(sql`delete from sys.task where workflow_id = ${id}`);
|
|
await container.db.execute(sql`delete from sys.node_instance where workflow_id = ${id}`);
|
|
await container.db.execute(sql`delete from sys.trace_span where workflow_id = ${id}`);
|
|
await container.db.execute(sql`delete from sys.workflow where id = ${id}`);
|
|
}
|
|
return taskCount;
|
|
}
|
|
|
|
/**
|
|
* 一封信 → 调用入参 —— **借的全是真件**:
|
|
* 收件地址认通道(真通道的抽键正则),包上挂的触发器出装配映射(真触发器的 mapping)。
|
|
*
|
|
* 测试台自己不解析邮件。它要是自带一套解析,试通的就是测试台而不是这条流 ——
|
|
* 线上真来一封信走的是通道抽键 + 触发器装配这两步,这里必须是同两步。
|
|
*/
|
|
async function assembleFromMail(
|
|
container: Container,
|
|
packageId: string,
|
|
mail: { from: string; to: string; subject: string; body: string; attachments: readonly { name: string }[] },
|
|
): Promise<Record<string, unknown>> {
|
|
const channels = await container.channel.list();
|
|
const channel =
|
|
channels.find((ch) => ch.address === mail.to) ?? channels.find((ch) => ch.kind === 'mail' && ch.enabled);
|
|
if (channel === undefined) throw new Error(`没有可用的邮件通道 —— 收件地址 ${mail.to} 对不上任何通道`);
|
|
|
|
const triggers = await container.trigger.list();
|
|
const forPackage = triggers.filter((t) => t.processPackageId === packageId);
|
|
const trigger = forPackage.find((t) => t.spec.kind === 'event') ?? forPackage[0];
|
|
if (trigger === undefined) {
|
|
throw new Error(`流程包 ${packageId} 还没有任何调起规则 —— 装配映射从触发器来,先去触发器区配一条`);
|
|
}
|
|
|
|
const keys = extractFields(
|
|
{ subject: mail.subject, body: mail.body, from: mail.from, to: [mail.to] },
|
|
channel.extract,
|
|
);
|
|
const scope = scopeOf({
|
|
type: 'data',
|
|
keys,
|
|
source: 'channel',
|
|
occurredAt: new Date().toISOString(),
|
|
payload: {
|
|
subject: mail.subject,
|
|
body: mail.body,
|
|
from: mail.from,
|
|
channelId: channel.id,
|
|
// 附件只登记不解析 —— 解析 Excel 是活动的事
|
|
attachments: mail.attachments.map((a) => a.name),
|
|
},
|
|
});
|
|
|
|
// 与起单取版同口径:必填字段读**生效版本**的调用接口
|
|
const active = (await container.process.listPackages()).find((x) => x.id === packageId && x.state === 'active');
|
|
const declared = active?.callInterface.input as { required?: string[] } | undefined;
|
|
const required = declared?.required ?? [];
|
|
const assembled = assembleInput(trigger, scope, required, (expression, evalScope) => {
|
|
try {
|
|
return evalSfeel(expression, evalScope);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
});
|
|
if (assembled.kind === 'missing') {
|
|
const wanted = channel.extract
|
|
.filter((r) => assembled.fields.includes(r.key))
|
|
.map((r) => `${r.key} 要匹配 /${r.regex}/`);
|
|
throw new Error(
|
|
`这封信里抽不出 ${assembled.fields.join('、')} —— 通道「${channel.name}」的抽键规则没命中` +
|
|
(wanted.length > 0 ? `(${wanted.join(';')})` : ''),
|
|
);
|
|
}
|
|
return assembled.data;
|
|
}
|
|
|
|
export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Container): void {
|
|
/**
|
|
* 岗位授权:客体 = 治理功能,动作按路由分级 —— **看是 `read`,动才是 `govern`**。
|
|
*
|
|
* 客体三类 × 动作三种的分法就是为此:BD 有 `task:read` 能看在途,却动不了控制事件;
|
|
* PM 有 `task:govern`,读写皆通(动作蕴含 govern ⊃ write ⊃ read)。
|
|
* 一律要 govern 会把只读监控也锁死,等于把三种动作压成一种。
|
|
*/
|
|
function guard(fn: GovernanceFunction, action: GrantAction) {
|
|
return async (account: string): Promise<ApiError | undefined> => {
|
|
const staff = await container.org.findByAccount(account);
|
|
if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' };
|
|
const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, action);
|
|
if (ok) return undefined;
|
|
const need = action === 'govern' ? '治理权' : '查看权';
|
|
return { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」${need}` };
|
|
};
|
|
}
|
|
|
|
const readTask = guard('task', 'read');
|
|
const governTask = guard('task', 'govern');
|
|
const readProcess = guard('process', 'read');
|
|
const governProcess = guard('process', 'govern');
|
|
const readTrigger = guard('trigger', 'read');
|
|
const governTrigger = guard('trigger', 'govern');
|
|
const readActivity = guard('activity', 'read');
|
|
const governActivity = guard('activity', 'govern');
|
|
const readTool = guard('tool', 'read');
|
|
const governTool = guard('tool', 'govern');
|
|
const readData = guard('data', 'read');
|
|
const readMemory = guard('memory', 'read');
|
|
|
|
/** 拒绝的 HTTP 语义:无权 403,无会话 401 */
|
|
const denyStatus = (body: ApiError): 401 | 403 => (body.code === 'forbidden' ? 403 : 401);
|
|
|
|
/* ── 任务管理:在途监控 · 控制 · 改派 · 任务日志 ──────────────────── */
|
|
|
|
app.get(listGovTasksRoute.path, async (c) => {
|
|
const denied = await readTask(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
|
|
const { staff } = await container.org.listStaff();
|
|
const nameOf = new Map(staff.map((s) => [s.id, s.name]));
|
|
const rows = await Promise.all(
|
|
staff.map(async (s) => (await container.task.listByOwner(s.id)).map((t) => ({ t, name: s.name }))),
|
|
);
|
|
const workflows = await container.process.listWorkflows(50);
|
|
|
|
return c.json({
|
|
tasks: rows.flat().map(({ t, name }) => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
activityId: t.activityId,
|
|
paradigm: t.paradigm,
|
|
ownerName: nameOf.get(t.ownerId) ?? name,
|
|
runtime: t.runtime,
|
|
...(t.terminal === undefined ? {} : { terminal: t.terminal }),
|
|
...(t.workflowId === undefined ? {} : { workflowId: t.workflowId }),
|
|
usedSteps: t.used.steps,
|
|
capSteps: t.caps.steps,
|
|
startedAt: t.startedAt,
|
|
})),
|
|
workflows: workflows.map((w) => ({
|
|
id: w.id,
|
|
subject: w.subject,
|
|
state: w.state,
|
|
packageId: w.packageId,
|
|
startedAt: w.startedAt,
|
|
})),
|
|
});
|
|
});
|
|
|
|
/** 控制三语义 —— 叫停留痕为最重档纠正信号 */
|
|
app.post(controlTaskRoute.path, async (c) => {
|
|
const denied = await governTask(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const staff = await container.org.findByAccount(c.get('account'));
|
|
const parsed = controlTaskRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success || staff === undefined) {
|
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
|
return c.json(body, 400);
|
|
}
|
|
const outcome = await container.task.control(
|
|
taskIdSchema.parse(c.req.param('taskId')),
|
|
parsed.data.semantic,
|
|
staff.id,
|
|
parsed.data.reason,
|
|
);
|
|
return c.json({ ok: true, resumable: outcome.resumable });
|
|
});
|
|
|
|
app.post(resumeTaskRoute.path, async (c) => {
|
|
const denied = await governTask(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const staff = await container.org.findByAccount(c.get('account'));
|
|
if (staff === undefined) return c.json({ code: 'unauthenticated', message: '会话无效' } satisfies ApiError, 401);
|
|
const task = await container.task.resume(taskIdSchema.parse(c.req.param('taskId')), staff.id);
|
|
return c.json({ ok: true, runtime: task.runtime });
|
|
});
|
|
|
|
app.post(reassignTaskRoute.path, async (c) => {
|
|
const denied = await governTask(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const staff = await container.org.findByAccount(c.get('account'));
|
|
const parsed = reassignTaskRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success || staff === undefined) {
|
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
|
return c.json(body, 400);
|
|
}
|
|
const task = await container.task.reassign(
|
|
taskIdSchema.parse(c.req.param('taskId')),
|
|
staffIdSchema.parse(parsed.data.to),
|
|
staff.id,
|
|
);
|
|
return c.json({ ok: true, ownerId: task.ownerId });
|
|
});
|
|
|
|
app.get(govTaskLogRoute.path, async (c) => {
|
|
const denied = await readTask(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId')));
|
|
return c.json({
|
|
log: lines.map((l) => ({ id: l.id, at: l.at, sideLabel: ATTRIBUTION_SIDE_LABEL[l.side], title: l.title })),
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 审批单 —— 审批点的产物,**不是任务**:没有预算、没有任务日志,只有批不批。
|
|
* 它在实现上是卡到审批人工位的验收工单,`taskId` 位坐的是 workflowId(见 wiring/engines.ts)。
|
|
*/
|
|
app.get(listApprovalsRoute.path, async (c) => {
|
|
const denied = await readTask(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
|
|
const workflows = await container.process.listWorkflows(50);
|
|
const perWorkflow = await Promise.all(
|
|
workflows.map(async (w) => {
|
|
const pkg = await container.process.getPackage(w.packageId, w.packageVersion);
|
|
if (pkg === undefined) return [];
|
|
const instances = await container.process.instancesOf(w.id);
|
|
const rows = await Promise.all(
|
|
instances
|
|
.filter((i) => i.activated && pkg.nodes.find((n) => n.id === i.nodeId)?.kind === 'approval')
|
|
.map(async (i) => {
|
|
const node = pkg.nodes.find((n) => n.id === i.nodeId);
|
|
// 审批人是运营时分配的,实例 assignee 即事实 —— 没派到的如实「—」
|
|
const assignee =
|
|
i.assigneeId === undefined
|
|
? undefined
|
|
: await container.org.getStaff(staffIdSchema.parse(i.assigneeId)).catch(() => undefined);
|
|
return {
|
|
id: `${w.id}:${i.nodeId}#${i.instance}`,
|
|
workflowId: w.id,
|
|
workflowSubject: w.subject,
|
|
title: `审批:${node?.label ?? i.nodeId}`,
|
|
approverName: assignee?.name ?? '—',
|
|
// 终局唯放行:released = 通过;其余终态 = 退回;未终态 = 待审批
|
|
state:
|
|
i.terminal === undefined
|
|
? ('pending' as const)
|
|
: i.terminal === 'released'
|
|
? ('passed' as const)
|
|
: ('rejected' as const),
|
|
issuedAt: w.startedAt,
|
|
};
|
|
}),
|
|
);
|
|
return rows;
|
|
}),
|
|
);
|
|
return c.json({ approvals: perWorkflow.flat() });
|
|
});
|
|
|
|
/**
|
|
* 仿真测试台 · 起一条流 —— **带节点指派**把整条流定向到指定账号。
|
|
*
|
|
* 这是「后端检测活动与流程」的入口:真实任务照旧落到被指派人的工作台
|
|
* (指派不是代答,人那一环还是真的),测试台只管起单与看穿全程。
|
|
*/
|
|
app.post(testRunRoute.path, async (c) => {
|
|
const denied = await governProcess(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const parsed = testRunRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
|
|
return c.json(body, 400);
|
|
}
|
|
const me = await container.org.findByAccount(c.get('account'));
|
|
const { packageId, mail, assignments } = parsed.data;
|
|
try {
|
|
const data = await assembleFromMail(container, packageId, mail);
|
|
// **一次只跑一条**:装配成了才拔上一单 —— 装配失败不该把在跑的那条也搭进去
|
|
await destroySimulations(container);
|
|
const record = await container.process.start({
|
|
packageId,
|
|
subject: mail.subject,
|
|
// 凡单必有来源:测试台起的单来源是「设计台试跑」,登记人即操作者
|
|
source: { kind: 'registration', ref: 'testbed', ...(me === undefined ? {} : { by: me.id }) },
|
|
data,
|
|
...(Object.keys(assignments).length === 0 ? {} : { assignments }),
|
|
});
|
|
return c.json({ workflowId: record.id, state: record.state });
|
|
} catch (error) {
|
|
// 起不来的原因要如实说(不合调用接口 / 无生效版 / 岗位空缺),别让人对着空白猜
|
|
const body: ApiError = { code: 'validation-failed', message: error instanceof Error ? error.message : String(error) };
|
|
return c.json(body, 400);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 停止仿真 —— **连根拔掉**:工作流、活动任务、工单、节点实例、trace 一并销毁。
|
|
* 仿真不是生产:试跑留下的任务不该在别人工作台上过夜,也不该混进在途口径。
|
|
*/
|
|
app.post(testStopRoute.path, async (c) => {
|
|
const denied = await governProcess(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const parsed = testStopRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' };
|
|
return c.json(body, 400);
|
|
}
|
|
const tasks = await destroySimulations(container, parsed.data.workflowId);
|
|
return c.json({ destroyed: true, tasks });
|
|
});
|
|
|
|
/** 当前在跑的仿真 —— 刷新页面也认得回来(测试台一次只跑一条) */
|
|
app.get(testCurrentRoute.path, async (c) => {
|
|
const denied = await governProcess(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const rows = await container.db.execute<{ id: string; subject: string }>(
|
|
sql`select id, subject from sys.workflow where source->>'ref' = 'testbed' order by started_at desc limit 1`,
|
|
);
|
|
const hit = rowsOf<{ id: string; subject: string }>(rows)[0];
|
|
return c.json(hit === undefined ? {} : { workflowId: hit.id, subject: hit.subject });
|
|
});
|
|
|
|
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
|
|
app.get(traceOfWorkflowRoute.path, async (c) => {
|
|
const denied = await governProcess(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const spans = await container.trace.ofWorkflow(c.req.param('id'));
|
|
return c.json({ spans });
|
|
});
|
|
|
|
/** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */
|
|
app.get(govWorkflowRoute.path, async (c) => {
|
|
const denied = await readTask(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
|
|
const workflow = await container.process.getWorkflow(c.req.param('id'));
|
|
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
|
|
if (pkg === undefined) {
|
|
const body: ApiError = { code: 'not-found', message: `流程包缺失:${workflow.packageId}` };
|
|
return c.json(body, 404);
|
|
}
|
|
const instances = await container.process.instancesOf(workflow.id);
|
|
const { staff } = await container.org.listStaff();
|
|
const nested = await Promise.all(
|
|
staff.map(async (s) => (await container.task.listByOwner(s.id)).map((t) => ({ t, owner: s.name }))),
|
|
);
|
|
|
|
return c.json({
|
|
workflow: {
|
|
id: workflow.id,
|
|
subject: workflow.subject,
|
|
state: workflow.state,
|
|
packageId: workflow.packageId,
|
|
packageVersion: workflow.packageVersion,
|
|
source: workflow.source,
|
|
startedAt: workflow.startedAt,
|
|
...(workflow.finishedAt === undefined ? {} : { finishedAt: workflow.finishedAt }),
|
|
},
|
|
pkg,
|
|
instances: instances.map((i) => ({
|
|
nodeId: i.nodeId,
|
|
instance: i.instance,
|
|
running: i.running,
|
|
waiting: i.waiting,
|
|
activated: i.activated,
|
|
...(i.terminal === undefined ? {} : { terminal: i.terminal }),
|
|
})),
|
|
tasks: nested
|
|
.flat()
|
|
.filter(({ t }) => t.workflowId === workflow.id)
|
|
.map(({ t, owner }) => ({ id: t.id, nodeId: t.nodeId ?? '', title: t.title, ownerName: owner })),
|
|
});
|
|
});
|
|
|
|
/* ── 工作流管理 ──────────────────────────────────────────────────── */
|
|
|
|
app.get(listProcessPackagesRoute.path, async (c) => {
|
|
const denied = await readProcess(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
return c.json({ packages: await container.process.listPackages() });
|
|
});
|
|
|
|
/**
|
|
* 保存编排 —— 保存 ≠ 发布:草稿可反复存;**已发布版本不可回改**。
|
|
* 编辑即其身:保存写回当前版本;在途钉版不可变,新版本只出自显式新建。
|
|
*/
|
|
app.put(saveProcessRoute.path, async (c) => {
|
|
const denied = await governProcess(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const parsed = saveProcessRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
|
|
return c.json(body, 400);
|
|
}
|
|
/*
|
|
* 编辑即其身:已存在的版本就地写回(号不变);asNew / 新流程首版才长新版本。
|
|
* 画图不检查(未生效版随手存);返回连同闭合报告 —— 异常看板据它列清单。
|
|
* 两道闸:在途钉版 409;写回生效版不闭合 412(生效的必须始终可运行)。
|
|
*/
|
|
try {
|
|
const pkg = await container.process.savePackage(parsed.data.pkg, { asNew: parsed.data.asNew === true });
|
|
return c.json({ pkg, report: await container.process.validate(pkg) });
|
|
} catch (error) {
|
|
if (error instanceof PinnedVersionError || error instanceof StaleVersionError) {
|
|
const body: ApiError = { code: 'conflict', message: error.message };
|
|
return c.json(body, 409);
|
|
}
|
|
if (error instanceof NotClosedError) {
|
|
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report };
|
|
return c.json(body, 412);
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
/** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */
|
|
app.post(setProcessStateRoute.path, async (c) => {
|
|
const denied = await governProcess(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const parsed = setProcessStateRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '启停状态不合协议' };
|
|
return c.json(body, 400);
|
|
}
|
|
try {
|
|
const packages = await container.process.setPackageState(
|
|
c.req.param('id'),
|
|
Number(c.req.param('version')),
|
|
parsed.data.state,
|
|
);
|
|
return c.json({ packages });
|
|
} catch (error) {
|
|
// 不闭合不得激活 —— 412 带整份报告,前端异常看板直接用
|
|
if (error instanceof NotClosedError) {
|
|
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report };
|
|
return c.json(body, 412);
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
/** 画图即校验 —— 与前端画布同源 */
|
|
app.post(validateProcessRoute.path, async (c) => {
|
|
const denied = await governProcess(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const parsed = validateProcessRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
|
|
return c.json(body, 400);
|
|
}
|
|
return c.json(await container.process.validate(parsed.data.pkg));
|
|
});
|
|
|
|
/* ── 触发器配置 ──────────────────────────────────────────────────── */
|
|
|
|
app.get(listTriggersRoute.path, async (c) => {
|
|
const denied = await readTrigger(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
return c.json({ triggers: await container.trigger.list() });
|
|
});
|
|
|
|
app.post(toggleTriggerRoute.path, async (c) => {
|
|
const denied = await governTrigger(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const parsed = toggleTriggerRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
|
return c.json(body, 400);
|
|
}
|
|
// 触发器的增删启停不改流程包的任何版本
|
|
await container.trigger.setEnabled(c.req.param('id'), parsed.data.enabled);
|
|
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);改定义同样不动任何流程包版本。
|
|
*/
|
|
app.put(upsertTriggerRoute.path, async (c) => {
|
|
const denied = await governTrigger(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const parsed = upsertTriggerRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '触发器不合 schema', detail: parsed.error.issues };
|
|
return c.json(body, 400);
|
|
}
|
|
return c.json({ trigger: await container.trigger.upsert(parsed.data.trigger) });
|
|
});
|
|
|
|
/* ── 活动库管理:两维分类 × 两条版本序列 ─────────────────────────── */
|
|
|
|
/**
|
|
* 装入 / 换版 —— 经**安装通道**,无代码通道(ADR-004):交清单 + .js 产物,都是数据。
|
|
* 两审在这里跑;不过审即 412 并把审计结论原样回给人看,不静默失败。
|
|
* 恒为新增、版本并存:装入不覆盖旧版本,在途任务按启动时版本跑完。
|
|
*/
|
|
app.post(installActivityRoute.path, async (c) => {
|
|
const denied = await governActivity(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const parsed = installActivityRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '活动清单不合 schema', detail: parsed.error.issues };
|
|
return c.json(body, 400);
|
|
}
|
|
const { manifest, artifact } = parsed.data;
|
|
const reply = (verdict: InstallVerdict, accepted: boolean) => ({
|
|
accepted,
|
|
activityId: manifest.identity.activityId,
|
|
packageVersion: manifest.identity.packageVersion,
|
|
staticFindings: verdict.staticFindings.map((f) => ({ code: f.code, severity: f.severity, message: f.message })),
|
|
replay: {
|
|
oldPassStillPass: verdict.replay.oldPassStillPass,
|
|
checked: verdict.replay.checked,
|
|
skippedNoHistory: verdict.replay.skippedNoHistory,
|
|
regressions: verdict.replay.regressions.map((r) => ({ taskId: r.taskId, detail: r.detail })),
|
|
},
|
|
});
|
|
try {
|
|
return c.json(reply(await container.registry.install(manifest, artifact), true));
|
|
} catch (error) {
|
|
if (error instanceof InstallRejectedError) {
|
|
/*
|
|
* 两审未过:不上架。**按 ApiError 形状回**(与发布门一致)——
|
|
* 类型化客户端只从 `detail` 取详情,直接回业务体的话前端拿不到审计结论。
|
|
*/
|
|
const body: ApiError = {
|
|
code: 'precondition-failed',
|
|
message: error.message,
|
|
detail: reply(error.verdict, false),
|
|
};
|
|
return c.json(body, 412);
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
app.get(listActivitiesRoute.path, async (c) => {
|
|
const denied = await readActivity(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const cards = await container.registry.list();
|
|
/*
|
|
* 契约面随卡一起给 —— 开发一个活动要照着的东西全在清单里:
|
|
* 七件契约的入参与出口、声明的工具/模型/域对象/岗位。
|
|
* 编排的人也要看它:引用一个活动却不知道它吃什么,边映射就只能靠猜。
|
|
*/
|
|
const faces = await Promise.all(
|
|
cards.map(async (card) => {
|
|
const manifest = await container.registry.manifestOf(card.activityId);
|
|
const data = manifest.contract.input.data as {
|
|
required?: string[];
|
|
properties?: Record<string, { type?: string }>;
|
|
};
|
|
const required = data.required ?? [];
|
|
const props = data.properties ?? {};
|
|
const portFields = (payload: unknown) => {
|
|
const p = payload as { properties?: Record<string, unknown> } | undefined;
|
|
return Object.keys(p?.properties ?? {});
|
|
};
|
|
return {
|
|
requiredInputs: required,
|
|
inputFields: Object.entries(props).map(([name, spec]) => ({
|
|
name,
|
|
type: spec.type ?? 'unknown',
|
|
required: required.includes(name),
|
|
})),
|
|
// 必发口与选择组一并列出:调用方只凭口名与 schema 就能衔接下游
|
|
outputPorts: [
|
|
...manifest.contract.output.mandatory,
|
|
...manifest.contract.output.selective.flatMap((g) => g.ports),
|
|
].map((port) => ({
|
|
name: port.name,
|
|
...(port.description === undefined ? {} : { description: port.description }),
|
|
fields: portFields(port.payload),
|
|
})),
|
|
tools: manifest.declarations.tools.map((t) => t.name),
|
|
models: manifest.declarations.models,
|
|
objects: manifest.declarations.ledger.map((o) => `${o.domain}.${o.object}`),
|
|
roles: [...manifest.contract.resources.roles],
|
|
humanCallBudget: manifest.contract.authorization.humanCallBudget,
|
|
};
|
|
}),
|
|
);
|
|
return c.json({
|
|
activities: cards.map((card, i) => ({
|
|
activityId: card.activityId,
|
|
name: card.name,
|
|
category: card.category,
|
|
paradigm: card.paradigm,
|
|
enabled: card.enabled,
|
|
packageVersions: card.packageVersions.map((p) => ({
|
|
version: p.version,
|
|
state: p.state,
|
|
installedAt: p.installedAt,
|
|
})),
|
|
assetVersions: card.assetVersions.map((a) => ({
|
|
kind: a.kind,
|
|
version: a.version,
|
|
effective: a.effective,
|
|
})),
|
|
contract: faces[i]!,
|
|
})),
|
|
});
|
|
});
|
|
|
|
/* ── 工具管理 ────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* 导入自定义工具 —— 与第一方走同一条门:静态审 + 试运行审,过了才入目录。
|
|
* 入目录不等于能用:还要治理台把它放进某个活动的白名单(授权在白名单与契约,不在活动代码)。
|
|
*/
|
|
app.post(registerToolRoute.path, async (c) => {
|
|
const denied = await governTool(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const parsed = registerToolRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '工具契约不合 schema', detail: parsed.error.issues };
|
|
return c.json(body, 400);
|
|
}
|
|
const { contract, samples, connectionValues } = parsed.data;
|
|
const verdict = await container.toolbox.register({
|
|
// 没给中文名就以逻辑名兜底 —— 主名不能空
|
|
contract: { ...contract, displayName: contract.displayName ?? contract.name },
|
|
// 期望形状统一按「出参是个对象」收:细的形状由契约 output schema 自己说
|
|
samples: samples.map((s) => ({ input: s.input, expectShape: { type: 'object' } })),
|
|
...(connectionValues === undefined ? {} : { connectionValues }),
|
|
});
|
|
return c.json({
|
|
accepted: verdict.accepted,
|
|
findings: verdict.staticFindings.map((f) => ({ code: f.code, severity: f.severity, message: f.message })),
|
|
trialRun: { ran: verdict.trialRun.ran, passed: verdict.trialRun.passed, failures: [...verdict.trialRun.failures] },
|
|
});
|
|
});
|
|
|
|
app.get(listToolsRoute.path, async (c) => {
|
|
const denied = await readTool(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const tools = await container.toolbox.list();
|
|
return c.json({
|
|
tools: tools.map((t) => ({
|
|
displayName: t.displayName,
|
|
name: t.name,
|
|
version: t.version,
|
|
description: t.description,
|
|
effect: t.effect,
|
|
pure: t.pure === true,
|
|
// 凭证隔离:只回键,永不回值(ADR-009)
|
|
connectionKeys: [...t.connectionKeys],
|
|
})),
|
|
});
|
|
});
|
|
|
|
/* ── 数据管理:域 / 对象目录 + 记录浏览 ─────────────────────────── */
|
|
|
|
app.get(listLedgerObjectsRoute.path, async (c) => {
|
|
const denied = await readData(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
return c.json({ objects: await container.ledger.listObjects() });
|
|
});
|
|
|
|
app.get(browseLedgerRoute.path, async (c) => {
|
|
const denied = await readData(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const address = { domain: c.req.param('domain'), object: c.req.param('object') };
|
|
const described = await container.ledger.describe(address);
|
|
if (described === undefined) {
|
|
const body: ApiError = { code: 'not-found', message: `台账对象未物化:${address.domain}.${address.object}` };
|
|
return c.json(body, 404);
|
|
}
|
|
// 记录浏览只读正式版 —— 跨任务只见过门的事实
|
|
const page = await container.ledger.query({ address, where: {}, orderBy: [], limit: 50, offset: 0 });
|
|
return c.json({
|
|
...(described.label === undefined ? {} : { label: described.label }),
|
|
columns: described.columns.map((col) => ({
|
|
name: col.name,
|
|
type: col.type,
|
|
...(col.label === undefined ? {} : { label: col.label }),
|
|
})),
|
|
primaryKey: [...described.primaryKey],
|
|
rows: page.rows,
|
|
total: page.total,
|
|
});
|
|
});
|
|
|
|
/* ── 记忆管理:活动记忆四层 ─────────────────────────────────────── */
|
|
|
|
/**
|
|
* 度量仪表 —— 自主率与纠正率**成对读**,复合为有效自主率。
|
|
* 只读近期任务的任务记忆聚合;度量本身不载权力。
|
|
*/
|
|
/*
|
|
* 度量八指标 —— 走 report 模块的**全组织**投影(staffId 传 undefined)。
|
|
*
|
|
* 此前这里调的是 memory 的五指标版,且把 submissions / corrections /
|
|
* judgements 四个入参硬塞 0 —— 纠正率与 DMN 覆盖率于是结构性恒为 0,
|
|
* 仪表在说谎。八指标本就在 report 里算好(§report/contract.ts:「治理台记忆区的
|
|
* 仪表读数,同源同口径」),这里只是把它接上,不另起一套口径。
|
|
*/
|
|
app.get(govMetricsRoute.path, async (c) => {
|
|
const denied = await readMemory(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const parsed = govMetricsRoute.query.safeParse(c.req.query());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '统计窗口不合法', detail: parsed.error.issues };
|
|
return c.json(body, 400);
|
|
}
|
|
const period = parsed.data.period ?? 'month';
|
|
const { staff } = await container.org.listStaff();
|
|
const nested = await Promise.all(staff.map((s) => container.task.listByOwner(s.id)));
|
|
const metrics = await container.report.metrics(undefined, period);
|
|
return c.json({ ...metrics, taskCount: nested.flat().length, period });
|
|
});
|
|
|
|
/* ── 系统管理 · 通道实例 ────────────────────────────────────────── */
|
|
|
|
app.get(listChannelsRoute.path, async (c) => {
|
|
const denied = await readTrigger(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const channels = await container.channel.list();
|
|
return c.json({
|
|
channels: channels.map((ch) => ({
|
|
id: ch.id,
|
|
name: ch.name,
|
|
kind: ch.kind,
|
|
address: ch.address,
|
|
enabled: ch.enabled,
|
|
extractCount: ch.extract.length,
|
|
extract: ch.extract.map((r) => ({ key: r.key, regex: r.regex, source: r.source })),
|
|
mappingCount: ch.mapping.length,
|
|
...(ch.fallbackPosition === undefined ? {} : { fallbackPosition: ch.fallbackPosition }),
|
|
addressWindowMs: ch.addressWindowMs,
|
|
})),
|
|
});
|
|
});
|
|
|
|
app.get(listActivityMemoryRoute.path, async (c) => {
|
|
const denied = await readMemory(c.get('account'));
|
|
if (denied) return c.json(denied, denyStatus(denied));
|
|
const entries = await container.memory.loadActivityMemory(c.req.param('activityId'), { limit: 100 });
|
|
return c.json({
|
|
entries: entries.map((e) => ({
|
|
id: e.id,
|
|
layer: e.layer,
|
|
statement: e.statement,
|
|
evidenceCount: e.evidence.length,
|
|
...(e.increment === undefined ? {} : { incrementState: e.increment.state }),
|
|
createdAt: e.createdAt,
|
|
})),
|
|
});
|
|
});
|
|
}
|