Archived
用户实测:「你怎么给我发成了执行工单,我需要的是验收工单,然后标题那一块需要是 验收卡的那种绿色」。 一、面孔:engines.ts 发执行工单那一处是一句写死只认 'data' 的三目,配方递下来的 'accept' 静默丢掉 —— 对话区照旧摆「执行工单」。**配方那一头有测(variant 原样递出去)、 前端那一头有测(accept 该穿哪副面孔)、e2e 按标题驱动也全绿,唯独中间没有。** 修:抽成纯函数 executionCardVariant(与旁边 decisionCardFace 同一种做法、同一个测试位), 认 data / accept、拒 review、认不出的当没写;4 测变异验证过。 e2e 同批改**认面孔不认标题**(试跑观测面的 cards 补 variant,契约变更)—— 标题是活动自己写的字符串,卡穿哪副面孔由装配层决定,那一次正是两者对不上。 二、颜色:件① 验收工单那一副整套强调色换成验收绿 #0aa294(令牌从 ACCENT_LEDGER 现改选择器得来,与件②③、对话区验收卡一处真源),题头那条写死的浅蓝渐变单独给一条绿的; 数据工单那一副仍是蓝。真渲染核过两副 class 与两套令牌。 验收:e2e w05 5/5 released、被拒 0(面孔断言这一轮真在跑);2563 测、四门禁绿; 2.16.0 装库。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
1499 lines
67 KiB
TypeScript
1499 lines
67 KiB
TypeScript
/**
|
||
* 治理台九功能的运行面路由(编码规范 §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,
|
||
...(o.mode === 'execution' && o.variant !== undefined ? { variant: o.variant } : {}),
|
||
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,
|
||
})),
|
||
});
|
||
});
|
||
}
|