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/apps/api/src/app/routes/governance-ops.ts
T
xcsandClaude Fable 5 7b85a79323 harness 与工作台同日修:数据工单确认即入库 · 叫了人不再自动推进(两道结构闸)· 审核卡 = 终局验收卡结构 · 验收清单人点达标 · 迟到守卫不丢放行裁决 · 画面跟着这一拍发出去的卡走 · 成果详情只翻页 · 占位居中 · 进度条
human-tool / ledger / loop-engine / release / task / interaction 六模块与 app 层(desk 路由、engines、consumers、
data-order-landing、case-stamp、reset-runs、seed-activities、容器与 server 装配),web 的 desk-task
(ViewRegion / chat-bubbles / review-face)与 app.css、ui-kit 图标;契约 card / manifest;
迁移 0034_task_seq 与迁移日志;api-report 快照;CLAUDE.md 快照、debt、活动开发规范、HTML 报告规范同步。
逐条口径见 CLAUDE.md 快照(2026-08-27 下午至晚)。另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-27 23:20:16 +08:00

1498 lines
67 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 { readUpload, storeUpload, UPLOAD_CAP } from '../upload-store.ts';
import type { Hono } from 'hono';
import type { ApiError } from '@contracts';
import {
browseLedgerRoute,
controlTaskRoute,
govMetricsRoute,
govTaskLogRoute,
govWorkflowRoute,
listActivitiesRoute,
listApprovalsRoute,
listChannelsRoute,
listActivityMemoryRoute,
listGovTasksRoute,
ledgerByActivityRoute,
listLedgerObjectsRoute,
installActivityRoute,
listProcessPackagesRoute,
listToolsRoute,
listTriggersRoute,
registerToolRoute,
saveProcessRoute,
testActivityStateRoute,
testCurrentRoute,
testLogDetailRoute,
testRunActivityRoute,
testRunRoute,
testStopRoute,
listTestLogsRoute,
deleteTestLogRoute,
clearTestLogsRoute,
downloadFileRoute,
uploadFileRoute,
traceOfWorkflowRoute,
setProcessStateRoute,
reassignTaskRoute,
resumeTaskRoute,
staffIdSchema,
taskIdSchema,
deleteApprovalTemplateRoute,
deleteProcessFlowRoute,
deleteProcessVersionRoute,
editProcessVersionRoute,
setNodeOwnerRoute,
setTriggerConfirmRoute,
listApprovalTemplatesRoute,
listNodeOwnersRoute,
toggleTriggerRoute,
upsertApprovalTemplateRoute,
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,
TemplateInUseError,
VersionTakenError,
} from '@modules/process';
import type { Container } from '../container.ts';
import { staffOf } from '../http-env.ts';
import type { AppEnv } from '../http-env.ts';
import { displayLog } from './log-lines.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'` 认门:**只动测试台自己起的单**,
* 生产单一个字都不碰(它们的来源是触发器或纪要登记)。
*/
/**
* 归档一次试跑 —— **销毁之前拍下快照**(见 `0023_testbed_log.sql` 的表注)。
*
* 拔掉运行数据这条不变;拔之前把观测面存下来:逐拍轨迹、台账草稿、发过的卡、
* 预算读数与终态。开发活动靠的就是「上一版跑了几拍、卡在哪一步」的比对。
*
* 归档失败**不挡销毁**:清场是人明确要的动作,不能因为存不下账就把仪器占着。
* 失败如实写进 stderr —— 静默吞掉会让人以为存了,回头找不着。
*/
async function archiveRun(
container: Container,
input: {
kind: 'activity' | 'package';
taskIds: readonly string[];
workflowId?: string;
subject?: string;
target?: string;
/** 流程包一次试跑的终态取流自己的,不是头一个节点任务的 */
terminal?: string;
},
): Promise<void> {
if (input.taskIds.length === 0) return;
try {
const trail: { at: string; kind: string; title: string; detail?: Record<string, unknown> }[] = [];
const rows: { address: string; key: Record<string, unknown>; patch: Record<string, unknown> }[] = [];
const cards: { id: string; mode: string; title: string; done: boolean; result?: string }[] = [];
const used = { steps: 0, tokens: 0, humanCalls: 0 };
const caps = { steps: 0, tokens: 0, humanCalls: 0 };
let head: { subject: string; target: string; terminal?: string; reason?: string; startedAt: string } | undefined;
for (const taskId of input.taskIds) {
const task = await container.task.get(taskId as never);
const [memory, drafts, orders] = await Promise.all([
container.memory.window(taskId as never, 400),
container.ledger.taskDrafts(taskId as never),
container.humanTool.allWorkOrders(taskId as never),
]);
// 多节点合成一条:每条痕带上是哪一节点的,不然合起来读不出先后归属
const tag = input.taskIds.length > 1 ? `${task.title} · ` : '';
for (const m of memory) {
trail.push({
at: m.at,
kind: m.kind,
title: `${tag}${m.title}`,
...(m.detail === undefined ? {} : { detail: m.detail }),
});
}
for (const d of drafts) rows.push({ address: `${d.domain}.${d.object}`, key: d.key, patch: d.patch });
for (const o of orders) {
cards.push({
id: o.id,
mode: o.mode,
title: `${tag}${o.title}`,
done: o.done !== undefined,
...(o.done?.result === undefined ? {} : { result: String(o.done.result) }),
});
}
used.steps += task.used.steps;
used.tokens += task.used.tokens;
used.humanCalls += task.used.humanCalls;
caps.steps += task.caps.steps;
caps.tokens += task.caps.tokens;
caps.humanCalls += task.caps.humanCalls;
head ??= {
subject: input.subject ?? task.title,
target: input.target ?? task.activityId,
...(input.terminal ?? task.terminal) === undefined ? {} : { terminal: input.terminal ?? task.terminal },
...(task.reason === undefined ? {} : { reason: task.reason }),
startedAt: task.startedAt,
};
}
if (head === undefined) return;
trail.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
await container.db.execute(sql`
insert into sys.testbed_run
(id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps, trail, drafts, cards, started_at)
values (
${crypto.randomUUID()}, ${input.kind}, ${head.subject}, ${head.target},
${input.taskIds[0] ?? null}, ${input.workflowId ?? null}, ${head.terminal ?? null}, ${head.reason ?? null},
${JSON.stringify(used)}::jsonb, ${JSON.stringify(caps)}::jsonb,
${JSON.stringify(trail)}::jsonb, ${JSON.stringify(rows)}::jsonb, ${JSON.stringify(cards)}::jsonb,
${head.startedAt}
)`);
} catch (error) {
process.stderr.write(`[testbed] 归档失败(照旧销毁):${error instanceof Error ? error.message : String(error)}\n`);
}
}
/** 试跑日志的库行 —— snake 出库、camel 出网,映射收在一处 */
type TestLogDbRow = {
id: string;
kind: string;
subject: string;
target: string;
task_id: string | null;
workflow_id: string | null;
terminal: string | null;
reason: string | null;
used: Record<string, number>;
caps: Record<string, number>;
trail_count: number;
draft_count: number;
card_count: number;
started_at: Date | string | null;
archived_at: Date | string;
};
const iso = (v: Date | string | null): string | undefined =>
v === null ? undefined : v instanceof Date ? v.toISOString() : String(v);
function toTestLogRow(r: TestLogDbRow): Record<string, unknown> {
return {
id: r.id,
kind: r.kind === 'package' ? 'package' : 'activity',
subject: r.subject,
target: r.target,
...(r.task_id === null ? {} : { taskId: r.task_id }),
...(r.workflow_id === null ? {} : { workflowId: r.workflow_id }),
...(r.terminal === null ? {} : { terminal: r.terminal }),
...(r.reason === null ? {} : { reason: r.reason }),
used: r.used ?? {},
caps: r.caps ?? {},
trailCount: Number(r.trail_count ?? 0),
draftCount: Number(r.draft_count ?? 0),
cardCount: Number(r.card_count ?? 0),
...(iso(r.started_at) === undefined ? {} : { startedAt: iso(r.started_at) }),
archivedAt: iso(r.archived_at) ?? new Date(0).toISOString(),
};
}
/** 一单任务的全部运行痕 —— 拔试跑单要拔干净:留在别人工作台上的卡最讨嫌 */
async function destroyTask(container: Container, taskId: string): Promise<void> {
for (const table of [
'work_order',
'chat_card',
'chat_message',
'task_memory',
'task_memory_seal',
'task_view',
'flow_state',
'loop_state',
'action_intent',
'listener',
'tool_handoff',
]) {
await container.db.execute(sql`delete from sys.${sql.raw(table)} where task_id = ${taskId}`);
}
// 草稿层是任务级的:试跑写下的草稿不该留在库里冒充别单的读己之写
await container.db.execute(sql`delete from ledger.__draft where task_id = ${taskId}`);
await container.db.execute(sql`delete from sys.trace_span where task_id = ${taskId}`);
await container.db.execute(sql`delete from sys.task where id = ${taskId}`);
}
/**
* 拔掉**单活动试跑**的残留 —— 认的是 `origin = 'testbed'` 且不挂工作流的那些单。
* 不挂工作流是关键:挂着的那批归 `destroySimulations` 按流拔,两边不重不漏。
*/
async function destroyActivityRuns(container: Container, taskId?: string): Promise<number> {
const scope = taskId === undefined || taskId === '' ? sql`` : sql` and id = ${taskId}`;
const rows = await container.db.execute<{ id: string }>(
sql`select id from sys.task where origin = 'testbed' and workflow_id is null${scope}`,
);
const ids = rowsOf<{ id: string }>(rows).map((r) => r.id);
for (const id of ids) {
await archiveRun(container, { kind: 'activity', taskIds: [id] });
await destroyTask(container, id);
}
return ids.length;
}
async function destroySimulations(container: Container, workflowId?: string): Promise<number> {
const scope = workflowId === undefined || workflowId === '' ? 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;
// 一次试跑 = 一条账:名下节点任务合成一条,先归档再拔
const head = rowsOf<{ subject: string; package_id: string; state: string }>(
await container.db.execute<{ subject: string; package_id: string; state: string }>(
sql`select subject, package_id, state from sys.workflow where id = ${id}`,
),
)[0];
await archiveRun(container, {
kind: 'package',
taskIds,
workflowId: id,
...(head === undefined ? {} : { subject: head.subject, target: head.package_id, terminal: head.state }),
});
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 {
/* ── 任务管理:在途监控 · 控制 · 改派 · 任务日志 ──────────────────── */
app.get(listGovTasksRoute.path, async (c) => {
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,
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 staff = staffOf(c);
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 staff = staffOf(c);
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 staff = staffOf(c);
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 lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId')));
return c.json({
// 与工作台同一份日志、同一套口径:章是四字动作,内容带主语
log: (await displayLog(lines, container.org)).map((l) => ({
id: l.id,
at: l.at,
sideLabel: l.sideLabel,
action: l.action,
title: l.title,
})),
});
});
/**
* 审批单 —— 审批点的产物,**不是任务**:没有预算、没有任务日志,只有批不批。
* 它在实现上是卡到审批人工位的验收工单,`taskId` 位坐的是 workflowId(见 wiring/engines.ts)。
*/
app.get(listApprovalsRoute.path, async (c) => {
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 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 = staffOf(c);
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 }),
});
/*
* 起单要说一声 —— 试跑起出来的是**真任务**:它这一刻就落到了负责人的工作台上、
* 进了运营台的在途口径。不广播,那两处要等到下一次自己重取才看得见
* (而工作台没有轮询,等于不刷新)。`system` 一并发:测试台自己的当前态也变了。
*/
container.broadcaster.publish({ kind: 'workflow', id: record.id, reason: 'testbed-start' });
container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-start' });
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 parsed = testStopRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' };
return c.json(body, 400);
}
const { workflowId, taskId } = parsed.data;
/*
* 两种试法各有其拔法。都不给 = 拔掉所有试跑残留(换一种试法之前先清场):
* 一次只跑一条,不然读数屏上是哪一单的数就说不清了。
*/
const byFlow = taskId === '' ? await destroySimulations(container, workflowId) : 0;
const byTask = workflowId === '' ? await destroyActivityRuns(container, taskId) : 0;
/*
* **销毁尤其要说一声**:被拔掉的任务此刻还挂在负责人的工作台列表上、还占着运营台的
* 在途口径。不广播,那些面上会留着一张点进去就 404 的鬼卡片,直到人自己刷新。
* 用 `system`:销毁是跨面的全局变更(任务没了、工作流没了、trace 也没了),
* 按单个 id 广播反而说不清 —— 说没就是全没了。
*/
if (byFlow + byTask > 0 || workflowId !== '' || taskId !== '') {
container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-destroyed' });
}
return c.json({ destroyed: true, tasks: byFlow + byTask });
});
/** 当前在跑的仿真 —— 刷新页面也认得回来(测试台一次只跑一条) */
app.get(testCurrentRoute.path, async (c) => {
/*
* **在跑与跑完要分得开**(`live`)。
*
* 从前只答「最近一条试跑单是哪个」,不问它有没有收尾 —— 于是跑完之后
* 这台仪器还认为自己被占着:旋钮永久锁住,人只能刷新整页才起得了下一单(实测)。
* 现在照旧把最近那一单交出去(观测面要摆结果给人看),但如实说明它还在不在跑。
*/
const flows = await container.db.execute<{ id: string; subject: string; state: string }>(
sql`select id, subject, state from sys.workflow
where source->>'ref' = 'testbed' order by started_at desc limit 1`,
);
const flow = rowsOf<{ id: string; subject: string; state: string }>(flows)[0];
if (flow !== undefined) {
const liveFlow = flow.state === 'running' || flow.state === 'waiting';
return c.json({
workflowId: flow.id,
subject: flow.subject,
live: liveFlow,
...(liveFlow ? {} : { terminal: flow.state }),
});
}
// 没有试跑的流,再看单活动试跑(它不挂工作流,认的是 origin)
const solo = await container.db.execute<{ id: string; activity_id: string; title: string; terminal: string | null }>(
sql`select id, activity_id, title, terminal from sys.task
where origin = 'testbed' and workflow_id is null order by started_at desc limit 1`,
);
const one = rowsOf<{ id: string; activity_id: string; title: string; terminal: string | null }>(solo)[0];
if (one === undefined) return c.json({ live: false });
return c.json({
taskId: one.id,
activityId: one.activity_id,
subject: one.title,
live: one.terminal === null,
...(one.terminal === null ? {} : { terminal: one.terminal }),
});
});
/**
* 仿真测试台 · 试跑**一个活动** —— 起一单不挂工作流的真任务。
*
* 真任务:照旧过装载两审、Harness 预算、案例模型推进、放行门。
* 「试跑不等于假跑」—— 假跑试不出任何东西;它与生产单的区别只有一个
* `origin='testbed'`,好让人随时连根拔掉。
*/
app.post(testRunActivityRoute.path, async (c) => {
const parsed = testRunActivityRoute.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 = staffOf(c);
const { activityId, data, message, ownerId } = parsed.data;
/*
* 派给谁:显式指定优先,否则**派给操作者自己**。
* 绝不默认派到别人工作台上 —— 试跑单是自己要看的,不是给同事添的活。
*/
const owner = ownerId !== '' ? ownerId : me?.id;
if (owner === undefined) {
const body: ApiError = { code: 'validation-failed', message: '定不了负责人:既没指派,也认不出操作者' };
return c.json(body, 400);
}
try {
// 一次只跑一条:先清场(流与单活动两种残留都拔)
await destroySimulations(container);
await destroyActivityRuns(container);
const manifest = await container.registry.manifestOf(activityId);
const task = await container.task.start({
activityId,
ownerId: staffIdSchema.parse(owner),
title: manifest.identity.name,
origin: 'testbed',
data,
...(message === '' ? {} : { message }),
});
/*
* **勾了「落真库」就在这一单上做个记号** —— 放行合并时主数据不再挡下来
* (见 `container.ts` 的 `isSimulated` 与 `ledger-service` 那道门)。
*
* 为什么落在路由里直接改一列,而不是让 `task.start` 收这个参数:
* 它是**试跑台这一处的破例**,不是任务本身的属性 ——
* 让整个任务模块的契约为一个试跑开关让路,是把临时的东西修进地基里。
*/
if (parsed.data.landMaster) {
await container.db.execute(sql`update sys.task set land_master = true where id = ${task.id}`);
}
container.broadcaster.publish({ kind: 'task', id: task.id, reason: 'testbed-start' });
container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-start' });
return c.json({ taskId: task.id, title: task.title });
} catch (error) {
// 起不来的原因要如实说(活动未装 / 入参不合契约 / 模型未绑),别让人对着空白猜
const body: ApiError = { code: 'validation-failed', message: error instanceof Error ? error.message : String(error) };
return c.json(body, 400);
}
});
/**
* 单活动试跑的观测面 —— 一屏看穿这一单。
*
* 流程包那侧看 trace 树(节点 → 步骤);活动这侧要看的是**活动内部**:
* 每一拍干了什么、拿回了什么、被拒的理由、成果落到台账哪一行、卡问了谁什么。
* 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。
*/
app.get(testActivityStateRoute.path, async (c) => {
const taskId = taskIdSchema.parse(c.req.param('taskId'));
/*
* **「这一单没了」是 404,不是 500**(2026-08-23 用户:「推进完成了,但测试台还在
* 运行中,没有停下来」查出来的后一半)。这一单被别处清掉(reset-runs / 另一台起单
* 先销毁上一单)之后,这条路由抛的是普通 Error → 兜底成 `500 internal`。
* 而 500 的意思是「服务器出毛病了,等会儿再来」——面板据此**继续举着上一次的状态
* 每 2 秒重试**,屏上就成了「AWAITING-EVENT · 自动刷新」配一条「任务不存在」。
* 状态码说错一格,前端就分不清「该松手」与「该等它回来」。
*/
const task = await container.task.get(taskId).catch(() => undefined);
if (task === undefined) {
const body: ApiError = { code: 'not-found', message: `这一单已经不在了(${taskId})` };
return c.json(body, 404);
}
const memory = await container.memory.window(taskId, 200);
const drafts = await container.ledger.taskDrafts(taskId);
const orders = await container.humanTool.allWorkOrders(taskId);
return c.json({
taskId: task.id,
activityId: task.activityId,
title: task.title,
runtime: task.runtime,
...(task.terminal === undefined ? {} : { terminal: task.terminal }),
...(task.reason === undefined ? {} : { reason: task.reason }),
used: { steps: task.used.steps, tokens: task.used.tokens, humanCalls: task.used.humanCalls },
caps: { steps: task.caps.steps, tokens: task.caps.tokens, humanCalls: task.caps.humanCalls },
trail: memory.map((m) => ({
at: m.at,
kind: m.kind,
title: m.title,
...(m.detail === undefined ? {} : { detail: m.detail }),
})),
drafts: drafts.map((d) => ({ address: `${d.domain}.${d.object}`, key: d.key, patch: d.patch })),
cards: orders.map((o) => ({
id: o.id,
mode: o.mode,
title: o.title,
done: o.done !== undefined,
...(o.done?.result === undefined ? {} : { result: String(o.done.result) }),
})),
});
});
/* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */
app.post(uploadFileRoute.path, async (c) => {
const parsed = uploadFileRoute.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 { name, contentType, dataBase64 } = parsed.data;
const bytes = new Uint8Array(Buffer.from(dataBase64, 'base64'));
if (bytes.length === 0) {
const body: ApiError = { code: 'validation-failed', message: `${name} 解出来是 0 字节 —— base64 不合法或文件是空的` };
return c.json(body, 400);
}
if (bytes.length > UPLOAD_CAP) {
const body: ApiError = {
code: 'validation-failed',
message: `${name} 有 ${(bytes.length / 1024 / 1024).toFixed(1)}MB,超过 8MB 上限 —— 这么大的件该走通道进来,不该从界面上传`,
};
return c.json(body, 400);
}
// 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note
return c.json(await storeUpload(container.db, { name, contentType, bytes, uploadedBy: staffOf(c)?.id ?? null }));
});
app.get(downloadFileRoute.path, async (c) => {
const hit = await readUpload(container.db, c.req.param('id'));
if (hit === undefined) {
const body: ApiError = { code: 'not-found', message: '这个文件不在了' };
return c.json(body, 404);
}
return c.json({ name: hit.name, contentType: hit.contentType, dataBase64: Buffer.from(hit.bytes).toString('base64') });
});
/* ── 试跑日志:历次试跑的存档,按实例管理 ────────────────────────── */
/** 列表只给读数与计数 —— 一次试跑的轨迹上百条,列表页不需要正文 */
app.get(listTestLogsRoute.path, async (c) => {
const rows = await container.db.execute<TestLogDbRow>(sql`
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
jsonb_array_length(trail) as trail_count,
jsonb_array_length(drafts) as draft_count,
jsonb_array_length(cards) as card_count,
started_at, archived_at
from sys.testbed_run order by archived_at desc limit 200`);
return c.json({ runs: rowsOf<TestLogDbRow>(rows).map(toTestLogRow) });
});
app.get(testLogDetailRoute.path, async (c) => {
const rows = await container.db.execute<TestLogDbRow & { trail: unknown; drafts: unknown; cards: unknown }>(sql`
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
jsonb_array_length(trail) as trail_count,
jsonb_array_length(drafts) as draft_count,
jsonb_array_length(cards) as card_count,
trail, drafts, cards, started_at, archived_at
from sys.testbed_run where id = ${c.req.param('id')}`);
const hit = rowsOf<TestLogDbRow & { trail: unknown; drafts: unknown; cards: unknown }>(rows)[0];
if (hit === undefined) {
const body: ApiError = { code: 'not-found', message: '这条试跑日志不存在(可能已被清掉)' };
return c.json(body, 404);
}
return c.json({ ...toTestLogRow(hit), trail: hit.trail ?? [], drafts: hit.drafts ?? [], cards: hit.cards ?? [] });
});
app.delete(deleteTestLogRoute.path, async (c) => {
const rows = await container.db.execute<{ id: string }>(
sql`delete from sys.testbed_run where id = ${c.req.param('id')} returning id`,
);
return c.json({ deleted: rowsOf<{ id: string }>(rows).length });
});
app.delete(clearTestLogsRoute.path, async (c) => {
const rows = await container.db.execute<{ id: string }>(sql`delete from sys.testbed_run returning id`);
return c.json({ deleted: rowsOf<{ id: string }>(rows).length });
});
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
app.get(traceOfWorkflowRoute.path, async (c) => {
const spans = await container.trace.ofWorkflow(c.req.param('id'));
return c.json({ spans });
});
/** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */
app.get(govWorkflowRoute.path, async (c) => {
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) => {
return c.json({ packages: await container.process.listPackages() });
});
/**
* 保存编排 —— 保存 ≠ 发布:草稿可反复存;**已发布版本不可回改**。
* 编辑即其身:保存写回当前版本;在途钉版不可变,新版本只出自显式新建。
*/
app.put(saveProcessRoute.path, async (c) => {
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) => {
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);
}
});
/* ── 审批模板(审批活动)—— 活动库里配、编排里引 ─────────────── */
app.get(listApprovalTemplatesRoute.path, async (c) => {
return c.json({ templates: await container.process.approvalTemplates() });
});
app.put(upsertApprovalTemplateRoute.path, async (c) => {
const parsed = upsertApprovalTemplateRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '审批模板不合协议(标识与名称必填)' };
return c.json(body, 400);
}
return c.json({ template: await container.process.saveApprovalTemplate(parsed.data.template) });
});
app.delete(deleteApprovalTemplateRoute.path, async (c) => {
try {
await container.process.deleteApprovalTemplate(c.req.param('id'));
return c.json({ ok: true });
} catch (error) {
if (error instanceof TemplateInUseError) {
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 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 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 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 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) => {
return c.json({ triggers: await container.trigger.list() });
});
app.post(toggleTriggerRoute.path, async (c) => {
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 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 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 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 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 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 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 };
title?: string;
format?: string;
/** 枚举值 —— 声明了就该给下拉选,不该让人对着自由文本框猜合法值 */
enum?: unknown[];
/** 枚举的人读名(与 `enum` 同序;JSON Schema 无此标准关键字,借业界通行写法) */
enumNames?: unknown[];
/** JSON Schema 标准关键字:示例值,填不出合法值时给个能抄的样子 */
examples?: unknown[];
/** 非标准扩展:另一个字段等于某值才出现这一格(级联下拉那类场景) */
showWhen?: { field?: string; equals?: 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');
/** 枚举 → 下拉选项:有 `enumNames` 配对就给人读名,没有就拿枚举值本身当标签 */
const optionsOf = (spec: FieldSpec | undefined): { value: string; label: string }[] | undefined => {
if (!Array.isArray(spec?.enum) || spec.enum.length === 0) return undefined;
return spec.enum.map((v, i) => ({ value: String(v), label: String(spec.enumNames?.[i] ?? v) }));
};
/** 示例值:JSON Schema `examples` 第一项,对象/数组类型序列化成能直接抄的 JSON 串 */
const exampleOf = (spec: FieldSpec | undefined): string | undefined => {
const first = spec?.examples?.[0];
if (first === undefined) return undefined;
return typeof first === 'string' ? first : JSON.stringify(first);
};
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),
// 清单里写了中文名就用中文名 —— 表单上摆 `customerEmail` 是给机器看的
...(spec?.title === undefined ? {} : { label: spec.title }),
// `format: 'file'` = 这一格收的是**真文件**,界面该给文件选择器而不是文本框
...(spec?.format === undefined ? {} : { format: spec.format }),
// 声明了枚举就给下拉选 —— 不该让人对着自由文本框猜合法值
...(optionsOf(spec) === undefined ? {} : { options: optionsOf(spec) }),
// 声明了示例就把它摆进占位提示,比裸类型名(`string`)更能抄
...(exampleOf(spec) === undefined ? {} : { example: exampleOf(spec) }),
// 声明了 showWhen 就只在另一格等于某值时才出现(级联下拉)
...(spec?.showWhen?.field === undefined || spec.showWhen.equals === undefined
? {}
: { showWhen: { field: spec.showWhen.field, equals: spec.showWhen.equals } }),
})),
// 包自带的样例集:试跑一个活动时照它预填(对着空表单编,编出来的多半跑不通)
samples: manifest.samples.map((sample) => ({ name: sample.name, input: sample.input })),
// 必发口与选择组一并列出:调用方只凭口名与 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,
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 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 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) => {
/*
* 性质随 `listObjects` **一次查询带回**(0026_object_meta)——
* 这里从前是拿 12 个活动的清单现场重建的:`manifestOf` 是 select *,
* 拉该活动全部版本的清单加产物再整份 zod parse,光 quote-intake 就 798kB、26 版,
* 一次请求约 1.5MB JSON。列表口不该做这种事。
*/
return c.json({ objects: await container.ledger.listObjects() });
});
/**
* **按活动看数据域** —— 「我这个活动写了哪几张表,现在各有几行,刚跑那一单落下了什么」。
*
* 数据只从三处来,一处不编:
* · 表与主键 —— 活动清单的台账声明(谁声明的,就归谁);
* · 行数 —— `describe` + `query` 的 total(正式表的真实行数;没物化就没有这个数,
* **不填 0** —— 「还没建表」与「建了但空着」是两件事);
* · 草稿行数与时刻 —— `ledger.__draft`(在办中的单写下的,放行才进正式表)。
*/
app.get(ledgerByActivityRoute.path, async (c) => {
const [cards, materialized] = await Promise.all([
container.registry.list(),
container.ledger.listObjects(),
]);
const known = new Map(materialized.map((o) => [`${o.domain}.${o.object}`, o]));
/** 草稿层:一次查完按地址分组 —— 每张表单独查会把这一屏拖成几十条 SQL */
const draftRows = rowsOf<{ domain: string; object: string; n: string; at: Date | string }>(
await container.db.execute(
// 已在数据工单确认时入库的行(landed_at 非空)不算「在办草稿」—— 它们已经在正式行数里了
sql`select domain, object, count(*)::text as n, max(updated_at) as at from ledger.__draft where landed_at is null group by domain, object`,
),
);
const drafts = new Map(
draftRows.map((r) => [
`${r.domain}.${r.object}`,
{ n: Number(r.n), at: r.at instanceof Date ? r.at.toISOString() : String(r.at) },
]),
);
/** 每个活动最近几单 —— 「刚跑完那一单」在这儿认得出来 */
const recentRows = rowsOf<{
activity_id: string;
id: string;
title: string;
runtime: string;
terminal: string | null;
at: Date | string;
}>(
await container.db.execute(sql`
select activity_id, id, title, runtime, terminal, coalesce(finished_at, started_at) as at
from (select *, row_number() over (partition by activity_id order by coalesce(finished_at, started_at) desc) as rn
from sys.task) t
where rn <= 3`),
);
const claimed = new Set<string>();
const activities = await Promise.all(
cards.map(async (card) => {
const manifest = await container.registry.manifestOf(card.activityId).catch(() => undefined);
const declared = manifest?.declarations.ledger ?? [];
const tables = await Promise.all(
declared.map(async (d) => {
const address = `${d.domain}.${d.object}`;
claimed.add(address);
const meta = known.get(address);
/*
* 行数要**真去数**:`listObjects` 只说有没有这张表。数不出来(表还没物化)
* 就不给这个数 —— 摆一个 0 会把「活动声明了但一次都没写过」说成「写过、现在空了」。
*/
const total =
meta === undefined
? undefined
: await container.ledger
.query({ address: { domain: d.domain, object: d.object }, where: {}, orderBy: [], limit: 1, offset: 0 })
.then((p) => p.total)
.catch(() => undefined);
const draft = drafts.get(address);
return {
domain: d.domain,
object: d.object,
...(d.label === undefined ? {} : { label: d.label }),
...(d.kind === undefined ? {} : { kind: d.kind }),
...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }),
primaryKey: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
...(total === undefined ? {} : { rows: total }),
drafts: draft?.n ?? 0,
...(draft === undefined ? {} : { draftAt: draft.at }),
};
}),
);
return {
activityId: card.activityId,
name: card.name ?? card.activityId,
/*
* 版本取**此刻在用的那一版** —— 按装入时刻取最新的 active。
* 不能拿「第一个 active」:一个活动装过二十几版,数组里前面躺的是老版本,
* 屏上就会写着 1.0.0 而实际跑的是 2.1.0(**界面不撒谎**,版本号尤其不能错)。
*/
...(((): { version?: string } => {
const usable = (card.packageVersions ?? []).filter((v) => v.state !== 'uninstalled');
const newest = [...usable].sort((a, b) => (a.installedAt < b.installedAt ? 1 : -1))[0];
return newest === undefined ? {} : { version: newest.version };
})()),
tables,
recent: recentRows
.filter((r) => r.activity_id === card.activityId)
.map((r) => ({
id: r.id,
title: r.title,
runtime: r.runtime,
...(r.terminal === null ? {} : { terminal: r.terminal }),
at: r.at instanceof Date ? r.at.toISOString() : String(r.at),
})),
};
}),
);
/* 库里有、没人声明的:多半是退役活动留下的空壳 —— 单列一档,不混进上面装作正常 */
const unclaimed = await Promise.all(
materialized
.filter((o) => !claimed.has(`${o.domain}.${o.object}`))
.map(async (o) => ({
domain: o.domain,
object: o.object,
...(o.label === undefined ? {} : { label: o.label }),
...((await container.ledger
.query({ address: { domain: o.domain, object: o.object }, where: {}, orderBy: [], limit: 1, offset: 0 })
.then((p) => ({ rows: p.total }))
.catch(() => ({}))) as { rows?: number }),
})),
);
return c.json({ activities, unclaimed });
});
app.get(browseLedgerRoute.path, async (c) => {
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 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 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 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,
})),
});
});
}