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-MVP/apps/api/src/app/routes/governance-ops.ts
T
ChaiHaoandClaude Opus 5 18c3e3fd09 测试台:抽键预检屏与收件箱格一并撤,台面改两栏对开
**抽键预检读数屏删了**。宽进之后它已经不是闸,只是一屏会亮会灭的字 ——
抽得出抽不出,进了活动由受理补全单说了算,不必在门口先演一遍。
镜像服务端正则的那份前端实现(`preflight`)随之退场:一套规则两处实现,
本就是迟早对不上的账。

**收件箱那一格删了**。投给哪个信箱是通道配置的事,不是造这封信的人该操心的 ——
契约里 `mail.to` 放宽为可空,空地址由服务端取在用的邮件通道(与线上真来一封信同一条路)。
信头从三行收成两行:发件人 / 主题 → 正文 → 附件签条。

**台面重排**:从「左读数屏 + 右两段」改成 **两栏对开** —— 左造信(01)、右派人(02),
起单条横跨两栏吸在底边,像仪器前面板压在桌沿上。被测流不再占一整栏:它是一次性的
决定,改成顶部一行卡片,且只在多于一条生效流时才出(没得选的选择器是纯占位)。

实测(丁子乔账号):不写收件箱照样起单成功,起后「停止仿真」连根销毁,
任务 0 / 工作流 0,试跑数据未留。五闸绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-29 22:51:09 +08:00

957 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 治理台九功能的运行面路由(编码规范 §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,
deleteProcessFlowRoute,
deleteProcessVersionRoute,
editProcessVersionRoute,
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 {
ActiveVersionError,
NotClosedError,
PinnedVersionError,
StaleVersionError,
VersionTakenError,
} 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 =
(mail.to === '' ? undefined : 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;
}
});
// 宽进:抽不全也照起 —— 缺的键由活动受理归一、补全单收齐(与线上事件起单同一条路)
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;
}
});
/** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */
/** 删一版:生效版先停用(409),在途钉版不许删(409)—— 那些单要按它跑完 */
app.delete(deleteProcessVersionRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
try {
const packages = await container.process.deleteVersion(c.req.param('id'), Number(c.req.param('version')));
return c.json({ packages });
} catch (error) {
if (error instanceof ActiveVersionError || error instanceof PinnedVersionError) {
const body: ApiError = { code: 'conflict', message: error.message };
return c.json(body, 409);
}
const body: ApiError = { code: 'not-found', message: error instanceof Error ? error.message : String(error) };
return c.json(body, 404);
}
});
/**
* 删整条流:连同全部版本。两道闸 —— 在途单钉着(409,用例层);
* **还有调起规则指着它也拒**(409,这一层查):删了包留着规则,那规则就是哑火的配置。
* 反查放在路由层而不是用例层:process 不该知道 trigger 的存在(L7 不依赖它)。
*/
app.delete(deleteProcessFlowRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const id = c.req.param('id');
const rules = (await container.trigger.list()).filter((t) => t.processPackageId === id);
if (rules.length > 0) {
const body: ApiError = {
code: 'conflict',
message: `还有 ${rules.length} 条调起规则指着这条流 —— 先去触发器区改掉或删掉它们`,
};
return c.json(body, 409);
}
try {
return c.json({ packages: await container.process.deleteFlow(id) });
} catch (error) {
if (error instanceof PinnedVersionError) {
const body: ApiError = { code: 'conflict', message: error.message };
return c.json(body, 409);
}
const body: ApiError = { code: 'not-found', message: error instanceof Error ? error.message : String(error) };
return c.json(body, 404);
}
});
/** 改版本记录:号自己定、名随之 —— 撞号与「跑过单改号」409,别的版一字不动 */
app.post(editProcessVersionRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = editProcessVersionRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '版本号要 1–9999 的整数,名字 1–40 字' };
return c.json(body, 400);
}
const version = Number(c.req.param('version'));
if (!Number.isInteger(version)) {
const body: ApiError = { code: 'validation-failed', message: '版本号要是整数' };
return c.json(body, 400);
}
const patch = {
...(parsed.data.version === undefined ? {} : { version: parsed.data.version }),
...(parsed.data.name === undefined ? {} : { name: parsed.data.name.trim() }),
};
try {
return c.json({ packages: await container.process.editVersion(c.req.param('id'), version, patch) });
} catch (error) {
if (error instanceof VersionTakenError || error instanceof PinnedVersionError) {
const body: ApiError = { code: 'conflict', message: error.message };
return c.json(body, 409);
}
const body: ApiError = { code: 'not-found', message: error instanceof Error ? error.message : String(error) };
return c.json(body, 404);
}
});
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);
type FieldSpec = { type?: string; items?: { type?: string } };
const data = manifest.contract.input.data as {
required?: string[];
properties?: Record<string, FieldSpec>;
};
const required = data.required ?? [];
const props = data.properties ?? {};
/** 类型的人读写法 —— **数组写成 `T[]`**:声明得看得出是一个还是一串 */
const typeOf = (spec: FieldSpec | undefined): string =>
spec?.type === 'array' ? `${spec.items?.type ?? 'any'}[]` : (spec?.type ?? 'unknown');
const portFields = (payload: unknown) => {
const p = payload as { properties?: Record<string, FieldSpec> } | undefined;
return Object.entries(p?.properties ?? {}).map(([name, spec]) => ({ name, type: typeOf(spec) }));
};
return {
requiredInputs: required,
inputFields: Object.entries(props).map(([name, spec]) => ({
name,
type: typeOf(spec),
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,
})),
});
});
}